Initial commit

This commit is contained in:
Viet An
2026-03-02 09:45:33 +07:00
commit d17a9e2588
415 changed files with 92113 additions and 0 deletions

5
.dockerignore Normal file
View File

@@ -0,0 +1,5 @@
node_modules
.output
.nuxt
dist
.git

24
.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Nuxt dev/build outputs
.output
.data
.nuxt
.nitro
.cache
dist
# Node dependencies
node_modules
# Logs
logs
*.log
# Misc
.DS_Store
.fleet
.idea
# Local env files
.env
.env.*
!.env.example

35
Dockerfile Normal file
View File

@@ -0,0 +1,35 @@
# --- STAGE 1: Build (Môi trường build) ---
FROM node:20-alpine AS builder
WORKDIR /app
# Copy các file cấu hình dependency
COPY package*.json ./
# Cài đặt mọi thư viện (bao gồm cả devDependencies để build)
RUN npm ci --legacy-peer-deps
# Copy toàn bộ source code và build dự án
COPY . .
RUN npm run build
# --- STAGE 2---
FROM node:20-alpine AS runner
WORKDIR /app
# Thiết lập môi trường Production
ENV NODE_ENV=production
# 1. Cài đặt PM2 toàn cục và dọn dẹp cache của npm ngay lập tức
RUN npm install pm2 -g && npm cache clean --force
# 2. Chỉ copy những file cần thiết nhất từ stage builder
# Nuxt 3/Nitro đã đóng gói mọi node_modules cần thiết vào .output
COPY --from=builder /app/.output ./.output
COPY --from=builder /app/ecosystem.config.cjs ./
COPY --from=builder /app/package*.json ./
EXPOSE 3000
CMD ["pm2-runtime", "ecosystem.config.cjs"]

75
README.md Normal file
View File

@@ -0,0 +1,75 @@
# Nuxt Minimal Starter
Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
## Setup
Make sure to install dependencies:
```bash
# npm
npm install
# pnpm
pnpm install
# yarn
yarn install
# bun
bun install
```
## Development Server
Start the development server on `http://localhost:3000`:
```bash
# npm
npm run dev
# pnpm
pnpm dev
# yarn
yarn dev
# bun
bun run dev
```
## Production
Build the application for production:
```bash
# npm
npm run build
# pnpm
pnpm build
# yarn
yarn build
# bun
bun run build
```
Locally preview production build:
```bash
# npm
npm run preview
# pnpm
pnpm preview
# yarn
yarn preview
# bun
bun run preview
```
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.

5
app.vue Normal file
View File

@@ -0,0 +1,5 @@
<template>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</template>

442
app/assets/styles/main.scss Normal file
View File

@@ -0,0 +1,442 @@
@use "sass:color";
@use "bulma/sass/utilities/initial-variables.scss" as *;
@use "bulma/sass/utilities/mixins.scss" as *;
@import url('https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900');
:root {
--bulma-family-primary: 'Inter', 'SF Pro', 'Helvetica', 'Arial', sans-serif;
}
// ==========================================
// 1. BRAND PALETTE (GAM MÀU TỪ LOGO)
// ==========================================
$blue-dianne: #204853; // Dark Cyan - Primary, Headings, Link
$parchment: #f2e5d6; // Cream - Accent, Background
$sirocco: #758385; // Greyish - Info, Border
$delta: #b0afaa; // Grey - Text phụ, Disabled
$cutty-sark: #566c72; // Blue Grey - Dark text, Warning text (Đậm)
$silver-rust: #ccc5bc; // Beige Grey - Line, Border nhạt
$fiord: #3c5b63; // Deep Green/Blue - Secondary, Success
$pewter: #959b99; // Medium Grey - Neutral
$pearl-bush: #f2e5d6; // Light Beige - Light background
$white-pure: #ffffff;
$black-pure: #000000;
$primary-color: $blue-dianne;
$secondary-color: $fiord;
$link-color: $blue-dianne;
$accent-color: $parchment;
$info-color: $sirocco;
$success-color: $fiord;
$warning-color: $cutty-sark;
$danger-color: #f14668;
$dark-color: $cutty-sark;
$light-color: $pearl-bush;
$neutral-color: $delta;
// ==========================================
// 2. COLOR MAP & SIZE
// ==========================================
$color: (
"primary": $primary-color,
"secondary": $secondary-color,
"link": $link-color,
"accent": $accent-color,
"info": $info-color,
"success": $success-color,
"warning": $warning-color,
"danger": $danger-color,
"dark": $dark-color,
"light": $light-color,
"delta": $delta,
"pewter": $pewter,
"silver": $silver-rust,
"white": $white-pure,
"black": $black-pure
);
$size: (
one: 10px,
two: 17px,
three: 30px,
four: 40px,
five: 50px,
six: 60px
);
// ==========================================
// 3. MIXINS & UTILITIES
// ==========================================
@mixin cbox($width, $height, $font, $background) {
display: flex;
width: $width;
height: $height;
border: 1.5px solid $sirocco;
font-size: $font;
-webkit-border-radius: 50%;
-moz-border-radius: 50%;
-ms-border-radius: 50%;
border-radius: 50%;
color: $sirocco;
font-weight: bold;
background-color: $background;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
}
// Tạo class cbox rỗng
@each $name, $hex in $color {
@each $n, $v in $size {
.cbox-#{$name}-#{$n} {
@include cbox($v*2, $v*2, $v*1.1, white);
border-color: $hex;
color: $hex;
}
}
}
// Tạo class cbox đặc
@each $name, $hex in $color {
@each $n, $v in $size {
.cbox-fill-#{$name}-#{$n} {
@include cbox($v*2, $v*2, $v, $hex);
border-color: $hex;
// Tự động tìm màu chữ tương phản
color: if(color.channel($hex, "lightness", $space: hsl) > 70, $blue-dianne, $white-pure);
}
}
}
// Font size loops
@for $i from 10 through 50 {
.fs-#{$i} { font-size: $i + px; }
.fsb-#{$i} { font-size: $i + px; font-weight: bold; }
}
// ==========================================
// 4. CUSTOM CLASSES
// ==========================================
.fullheight { height: 100vh; }
.textsize {
@include mobile { font-size: 18px; }
}
.header-logo {
background: url('/logo_dev.png') no-repeat center center;
background-size: 40px;
width: 50px;
}
.border-bottom { border-bottom: 1px solid color.change($black-pure, $alpha: 0.15) }
.carousel-height {
width: 100%;
height: 80vh;
@include mobile { height: 110vh; }
}
// Mobile Spacing Utilities
.mobile-mt20 { @include mobile { margin-top: 20px; } }
.mobile-px10 { @include mobile { padding-left: 10px; padding-right: 10px; } }
.mobile-pt10 { @include mobile { padding-top: 10px; } }
.mobile-pt80 { padding-top: 120px; @include mobile { padding-top: 80px; } }
.fullhd-pt30 {
padding-top: 30px;
@include until($fullhd) { padding-top: 0px; }
}
.media-width {
width: 120px !important;
@include mobile { width: 112px !important; }
}
.hideon-mobile { @include mobile { display: none; } }
// Typography Classes
.maintext {
margin-top: 20px;
font-size: 40px;
line-height: 3rem;
font-weight: 600;
color: $blue-dianne;
@include mobile { font-size: 34px; }
}
.subtext {
margin-top: 30px;
font-size: 1.2rem;
line-height: 2rem;
color: $cutty-sark;
@include mobile { line-height: 1.8rem; }
}
.dotslide {
position: fixed;
bottom: 0;
position: absolute;
width: 100%;
text-align: center;
z-index: 999;
}
.activetab {
border-radius: 8px;
color: $white-pure;
background-color: $blue-dianne;
}
// Block Layout
.blockdiv {
max-width: 1900px !important;
background-color: $white-pure;
padding: 60px 15px 40px 15px;
@include until($desktop) { padding: 65px 20px 30px 20px; }
@include mobile { padding: 65px 16px 30px 16px; }
.columns .column {
@include mobile { padding-left: 0; padding-right: 0; }
}
.padding-desktop {
@media screen and (min-width: $desktop) { padding-left: 20px; padding-right: 20px; }
}
}
.padding-text {
padding-left: 15%; padding-right: 5%;
@include until($desktop) { padding-left: 0; padding-right: 0; }
@include until($fullhd) { padding-left: 0; padding-right: 0; }
}
.padding-image {
padding-left: 5%; padding-right: 15%;
@include until($fullhd) { padding-left: 0; padding-right: 0; }
@include until($desktop) { padding-left: 15%; padding-right: 15%; }
@include mobile { padding-left: 0; padding-right: 0; }
}
.imgcontainer {
position: relative;
width: 100% !important;
max-width: 500px;
}
.centered {
position: absolute;
top: 80%;
text-align: center;
}
// Tooltip Styles
.tooltip {
position: relative;
display: inline-block;
cursor: pointer;
color: $white-pure;
}
.tooltip .tooltiptext {
visibility: hidden;
background-color: $fiord;
color: $parchment;
border-radius: 6px;
position: absolute;
margin-left: 0px;
z-index: 999;
bottom: 110%;
opacity: 0;
transition: opacity 0.3s;
padding: 6px;
font-size: 14px;
pointer-events: none;
}
.to-left { right: 30px; }
@mixin tooltipshow() {
visibility: visible;
opacity: 1;
position: absolute;
min-width: 300px;
z-index: 999;
background-color: $pearl-bush;
color: $blue-dianne;
border: 1px solid $silver-rust;
}
.tooltip:hover .tooltiptext { @include tooltipshow() }
.tooltip:hover .tooltiptext .to-left { @include tooltipshow() }
// Dot Indicators
@mixin dot($background) {
height: 22px;
width: 22px;
text-align: center;
color: $white-pure;
font-weight: bold;
background-color: $background;
display: inline-block;
cursor: pointer;
font-size: 15px;
border-radius: 50%;
}
@each $name, $hex in $color {
.dot-#{$name} {
@include dot($hex);
}
}
// ==========================================
// 5. HELPER CLASSES GENERATOR
// ==========================================
@each $name, $hex in $color {
.bg-#{$name} { background-color: $hex !important; }
.text-#{$name} { color: $hex !important; }
.border-#{$name} { border-color: $hex !important; }
.icon-#{$name} { color: $hex !important; }
.icon-bg-#{$name} {
background-color: $hex !important;
color: if(color.channel($hex, "lightness", $space: hsl) > 70, $blue-dianne, $white-pure) !important;
padding: 0.5rem;
border-radius: 4px;
}
}
// ==========================================
// 6. BULMA OVERRIDES
// ==========================================
// Backgrounds
.has-background-primary { background-color: $primary-color !important; }
.has-background-secondary { background-color: $secondary-color !important; }
.has-background-info { background-color: $info-color !important; }
.has-background-success { background-color: $success-color !important; }
.has-background-warning { background-color: $warning-color !important; }
.has-background-danger { background-color: $danger-color !important; }
.has-background-light { background-color: $light-color !important; }
.has-background-dark { background-color: $dark-color !important; }
.has-background-white { background-color: $white-pure !important; }
// Text Colors
.has-text-primary { color: #086e71 !important; }
.has-text-secondary { color: $secondary-color !important; }
.has-text-info { color: $info-color !important; }
.has-text-success { color: $success-color !important; }
.has-text-warning { color: $warning-color !important; }
.has-text-danger { color: $danger-color !important; }
.has-text-light { color: $accent-color !important; }
.has-text-dark { color: $dark-color !important; }
// Button/Element States (is-*)
.is-primary {
background-color: $primary-color !important;
border-color: transparent !important;
color: $white-pure !important;
}
.is-secondary {
background-color: $secondary-color !important;
border-color: transparent !important;
color: $white-pure !important;
}
.is-link {
background-color: $link-color !important;
border-color: transparent !important;
color: $white-pure !important;
}
.is-info {
background-color: $info-color !important;
border-color: transparent !important;
color: $white-pure !important;
}
.is-success {
background-color: $success-color !important;
border-color: transparent !important;
color: $white-pure !important;
}
.is-warning {
background-color: $warning-color !important;
border-color: transparent !important;
color: $white-pure !important;
}
.is-danger {
background-color: $danger-color !important;
border-color: transparent !important;
color: $white-pure !important;
}
.is-light {
background-color: $light-color !important;
border-color: transparent !important;
color: $blue-dianne !important;
}
.is-dark {
background-color: $dark-color !important;
border-color: transparent !important;
color: $white-pure !important;
}
// Outlined Variants
.is-primary.is-outlined {
background-color: transparent !important;
border-color: $primary-color !important;
color: $primary-color !important;
}
.is-link.is-outlined {
background-color: transparent !important;
border-color: $link-color !important;
color: $link-color !important;
}
.is-info.is-outlined {
background-color: transparent !important;
border-color: $info-color !important;
color: $info-color !important;
}
.is-success.is-outlined {
background-color: transparent !important;
border-color: $success-color !important;
color: $success-color !important;
}
.is-warning.is-outlined {
background-color: transparent !important;
border-color: $warning-color !important;
color: $warning-color !important;
}
.is-primary.is-light {
background-color: rgba($primary-color, 0.1) !important;
color: $primary-color !important;
}
.is-info.is-light {
background-color: rgba($info-color, 0.2) !important;
color: color.adjust($info-color, $lightness: -10%) !important;
}
.is-success.is-light {
background-color: rgba($success-color, 0.1) !important;
color: $success-color !important;
}

View File

@@ -0,0 +1,14 @@
<template>
<span :class="`icon-text fsb-${props.size||17} ${props.type || 'has-text-primary'}`">
<span>{{ title }}</span>
<SvgIcon id="ignore" v-bind="{name: 'right.svg', type: props.type? props.type.replace('has-text-', '') : null,
size: (props.size>=30? props.size*0.7 : props.size) || 20, alt: 'Mũi tên chỉ hướng'}"></SvgIcon>
</span>
</template>
<script setup>
var props = defineProps({
type: String,
size: Number,
title: String
})
</script>

133
app/components/Modal.vue Normal file
View File

@@ -0,0 +1,133 @@
<template>
<Teleport to="#__nuxt > div">
<div class="modal is-active" @click="doClick">
<div
class="modal-background"
:style="`opacity:${count === 0 ? 0.7 : 0.3} !important;`"
></div>
<div
class="modal-card"
:id="docid"
:style="`width:${vWidth}; border-radius:16px;`"
>
<header class="modal-card-head my-0 py-2" v-if="title">
<div style="width: 100%">
<div class="field is-grouped">
<div class="control is-expanded has-text-left">
<p class="fsb-18 has-text-primary" v-html="title"></p>
</div>
<div class="control has-text-right">
<button class="delete is-medium" @click="closeModal()"></button>
</div>
</div>
</div>
</header>
<section
class="modal-card-body px-4 py-4"
:style="`min-height:${
height ? height : '750px'
};border-bottom-left-radius:16px; border-bottom-right-radius:16px;`"
>
<component
:is="resolvedComponent"
v-bind="props.vbind"
@modalevent="modalEvent"
@close="closeModal"
/>
</section>
</div>
</div>
</Teleport>
</template>
<script setup>
import { onMounted, defineAsyncComponent, shallowRef, watchEffect } from "vue";
import { useStore } from "@/stores/index";
const emit = defineEmits(["close", "remove", "select", "dataevent", "update"]);
const store = useStore();
const { $id } = useNuxtApp();
const props = defineProps({
component: String,
width: String,
height: String,
vbind: Object,
title: String,
});
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 viewport = store.viewport;
const docid = $id();
const title = props.title;
let count = 0;
const lock = false;
const vWidth = viewport <= 2 ? "100%" : props.width || "60%";
const closeModal = function () {
if (!lock) emit("close");
};
const modalEvent = function (ev) {
if (ev.name === "select") {
emit("select", ev.data);
} else if (ev.name === "dataevent") {
emit("dataevent", ev.data);
} else if (ev.name === "update") {
emit("update", ev.data);
} else {
emit(ev.name, ev.data);
}
};
const doClick = function (e) {
if (!e.srcElement.offsetParent) return;
const el = document.getElementById(docid);
if (el && !el.contains(e.target)) {
closeModal();
}
};
onMounted(() => {
document.documentElement.classList.add('is-clipped');
window.addEventListener("keydown", (e) => {
if (e.key === "Escape") closeModal();
});
const collection = document.getElementsByClassName("modal-background");
count = collection.length;
});
onUnmounted(() => {
count--;
if (count === 0) document.documentElement.classList.remove('is-clipped');
})
</script>

View File

@@ -0,0 +1,275 @@
<template>
<div>
<div class="field has-addons" :id="docid">
<div class="control has-icons-left is-expanded">
<div :class="`dropdown ${ pos || ''} ${focused? 'is-active' : ''}`" style="width: 100%;">
<div class="dropdown-trigger" style="width: 100%;">
<input
:disabled="disabled"
:class="`input ${error? 'is-danger' : ''} ${disabled? 'has-text-dark' : ''}`"
type="text"
@focus="setFocus"
@blur="lostFocus"
@keyup.enter="pressEnter"
@keyup="beginSearch"
v-model="value"
:placeholder="placeholder"
/>
</div>
<div class="dropdown-menu" style="min-width: 100%" role="menu" @click="doClick()">
<div class="dropdown-content px-3" style="min-width: 100%;">
<p class="has-text-warning" v-if="data.length===0">{{ isVietnamese ? 'Không giá trị thỏa mãn' : 'No matching values' }}</p>
<ScrollBox v-bind="{data: data, name: field, fontsize: 14, maxheight: '200px', notick: true}" @selected="choose" v-else></ScrollBox>
</div>
</div>
</div>
<span class="icon is-left">
<SvgIcon v-bind="{name: 'magnify.svg', type: 'gray', size: 22}"></SvgIcon>
</span>
</div>
<div class="control" v-if="clearable && value">
<button class="button is-primary px-2" @click="clearValue" style="height: 100%" type="button">
<SvgIcon v-bind="{name: 'close.svg', type: 'white', size: 24}"></SvgIcon>
</button>
</div>
<div class="control" v-if="viewaddon">
<button class="button is-dark px-2" @click="viewInfo()" style="height: 100%" type="button">
<SvgIcon v-bind="{name: 'view.svg', type: 'white', size: 24}"></SvgIcon>
</button>
</div>
<div class="control" v-if="addon">
<button class="button is-primary px-2" @click="addNew()" style="height: 100%" type="button">
<SvgIcon v-bind="{name: 'add1.png', type: 'white', size: 24}"></SvgIcon>
</button>
</div>
</div>
<Modal @dataevent="dataevent" @close="showmodal=undefined" v-bind="showmodal" v-if="showmodal"></Modal>
</div>
</template>
<script>
import { useStore } from "@/stores/index";
export default {
props: ['api', 'field', 'column', 'first', 'optionid', 'filter', 'addon', 'viewaddon', 'position', 'disabled', 'vdata', 'clearable', 'placeholder', 'searchfield'],
setup() {
const store = useStore();
return { store };
},
data() {
return {
search: undefined,
data: [],
timer: undefined,
value: undefined,
selected: undefined,
showmodal: undefined,
params: this.api? this.$findapi(this.api)['params'] : undefined,
orgdata: undefined,
error: false,
focused: false,
count1: 0,
count2: 0,
docid: this.$id(),
pos: undefined,
}
},
computed: {
isVietnamese() {
return this.store.lang === "vi";
}
},
async created() {
this.getPos()
if(this.vdata) {
this.orgdata = this.$copy(this.vdata)
this.orgdata.map(v=>v.search = this.$nonAccent(v[this.field]))
}
if(this.first) {
this.data = this.orgdata? this.$copy(this.orgdata) : await this.getData()
if(this.optionid) {
let f = {}
f[this.field] = this.optionid
if(this.optionid>0) f = {id: this.optionid}
this.selected = this.$find(this.data, f)
if(this.selected && this.vdata) {
return this.value = this.selected[this.field]
}
}
} else if(this.optionid) {
this.selected = await this.$getdata(this.api, {id: this.optionid}, undefined, true)
}
if(this.selected) this.doSelect(this.selected)
},
watch: {
optionid: function(newVal) {
if(this.optionid) this.selected = this.$find(this.data, {id: this.optionid})
if(this.selected) this.doSelect(this.selected)
else this.value = undefined
},
filter: async function(newVal) {
this.data = await this.getData()
},
vdata: function(newval) {
if(newval) {
this.orgdata = this.$copy(this.vdata)
this.orgdata.map(v=>v.search = this.$nonAccent(v[this.field]))
this.data = this.$copy(this.orgdata)
this.selected = undefined
this.value = undefined
if(this.optionid) this.selected = this.$find(this.data, {id: this.optionid})
if(this.selected) this.doSelect(this.selected)
}
}
},
methods: {
choose(v) {
this.focused = false
this.count1 = 0
this.count2 = 0
this.doSelect(v)
},
setFocus() {
this.focused = true
this.count1 = 0
this.count2 = 0
},
lostFocus() {
let self = this
setTimeout(()=>{
if(self.focused && self.count1===0) self.focused = false
}, 200)
},
pressEnter() {
if(this.data.length===0) return
this.choose(this.data[0])
},
doClick() {
this.count1 += 1
},
doSelect(option) {
if(this.$empty(option)) return
this.$emit('option', option)
this.$emit('modalevent', {name: 'option', data: option})
this.selected = option
this.value = this.selected[this.field]
},
clearValue() {
this.value = undefined
this.selected = undefined
this.$emit('option', null)
this.$emit('modalevent', {name: 'option', data: null})
},
findObject(val) {
let rows = this.$copy(this.orgdata)
if(this.$empty(val)) this.data = rows
else {
let text = this.$nonAccent(val)
this.data = rows.filter(v=>v.search.toLowerCase().indexOf(text.toLowerCase())>=0)
}
},
async getData() {
this.params.filter = this.filter
let data = await this.$getdata(this.api, undefined, this.params)
return data
},
async getApi(val) {
if(this.vdata) return this.findObject(val)
let text = val? val.toLowerCase() : ''
let f = {}
// Sử dụng searchfield nếu có, nếu không thì dùng column
const fieldsToSearch = this.searchfield || this.column;
fieldsToSearch.map(v=>{
f[`${v}__icontains`] = text
})
this.params.filter_or = f
if(this.filter) this.params.filter = this.$copy(this.filter)
let arr = await this.$getdata(this.api, undefined, this.params)
this.data = this.$copy(arr)
},
beginSearch(e) {
let val = e.target.value
this.search = val
if (this.timer) clearTimeout(this.timer)
this.timer = setTimeout(() => this.getApi(val), 150)
},
addNew() {
this.showmodal = this.$copy(this.addon)
},
dataevent(v) {
console.log("SearchBox received dataevent:", v); // Debug log
if (!v || !v.id) {
console.error("Invalid data received in SearchBox:", v);
return;
}
// Tìm và cập nhật trong danh sách
let idx = this.$findIndex(this.data, {id: v.id})
if (idx < 0) {
// Nếu chưa có trong danh sách, thêm vào đầu
this.data.unshift(v);
console.log("Added new item to data:", v);
} else {
// Nếu đã có, cập nhật
this.data[idx] = v;
console.log("Updated existing item in data:", v);
}
// Cập nhật orgdata nếu có
if (this.orgdata) {
let orgIdx = this.$findIndex(this.orgdata, {id: v.id});
if (orgIdx < 0) {
this.orgdata.unshift(v);
// Thêm search field cho orgdata
if (this.field && v[this.field]) {
v.search = this.$nonAccent(v[this.field]);
}
} else {
this.orgdata[orgIdx] = v;
if (this.field && v[this.field]) {
this.orgdata[orgIdx].search = this.$nonAccent(v[this.field]);
}
}
}
// **Tự động select item vừa tạo/cập nhật**
this.doSelect(v);
// Đóng modal
this.showmodal = undefined;
console.log("SearchBox data after update:", this.data);
},
viewInfo() {
if(!this.selected) return this.$dialog(this.isVietnamese ? 'Vui lòng lựa chọn trước khi xem thông tin.' : 'Please select before viewing', this.isVietnamese ? 'Thông báo' : 'Notice')
let copy = this.$copy(this.viewaddon)
copy.vbind = {row: this.selected}
this.showmodal = copy
},
getPos() {
switch(this.position) {
case 'is-top-left':
this.pos = 'is-up is-left'
break;
case 'is-top-right':
this.pos = 'is-up is-right'
break;
case 'is-bottom-left':
this.pos = 'is-right'
break;
case 'is-bottom-right':
this.pos = 'is-right'
break;
}
}
}
}
</script>
<style scoped>
.field:not(:last-child) {
margin-bottom: 0;
}
</style>

View File

@@ -0,0 +1,72 @@
<template>
<nuxt-img
loading="lazy"
:alt="alt"
:class="`svg-${type || 'findata'}`"
:style="`width: ${size || 26}px;`"
:src="`/icon/${name || 'check.svg'}`"
/>
</template>
<script>
export default {
props: ["name", "size", "type", "alt"],
};
</script>
<style>
/* primary: $blue-dianne (#204853) */
.svg-primary {
filter: invert(19%) sepia(18%) saturate(1514%) hue-rotate(151deg) brightness(97%) contrast(85%);
}
/* secondary: $fiord (#3c5b63) */
.svg-secondary {
filter: invert(27%) sepia(12%) saturate(1506%) hue-rotate(153deg) brightness(96%) contrast(87%);
}
/* accent: $parchment (#f2e5d6) */
.svg-accent {
filter: invert(93%) sepia(13%) saturate(1217%) hue-rotate(331deg) brightness(101%) contrast(90%);
}
/* findata/info/warning: $sirocco (#758385) */
/* Cả ba đều dùng chung bộ lọc này */
.svg-findata,
.svg-info,
.svg-warning {
filter: invert(56%) sepia(10%) saturate(301%) hue-rotate(167deg) brightness(92%) contrast(82%);
}
/* danger: $danger-red (#f14668) */
.svg-danger {
filter: invert(34%) sepia(91%) saturate(2465%) hue-rotate(332deg) brightness(100%) contrast(97%);
}
/* success: $forest-green (#2F7C4E) */
.svg-success {
filter: invert(20%) sepia(21%) saturate(2657%) hue-rotate(119deg) brightness(97%) contrast(86%);
}
/* dark: $cutty-sark (#566c72) */
.svg-dark {
filter: invert(36%) sepia(11%) saturate(1009%) hue-rotate(152deg) brightness(96%) contrast(85%);
}
/* light: $pearl-bush (#e3d8cb) */
.svg-light {
filter: invert(92%) sepia(21%) saturate(233%) hue-rotate(334deg) brightness(100%) contrast(91%);
}
/* twitter: $pewter (#959b99) */
.svg-twitter {
filter: invert(70%) sepia(7%) saturate(271%) hue-rotate(170deg) brightness(94%) contrast(89%);
}
/* Các màu cơ bản tiện ích */
.svg-white {
filter: brightness(0) invert(1);
}
.svg-black {
filter: invert(0%) sepia(100%) saturate(0%) hue-rotate(235deg) brightness(107%) contrast(107%);
}
</style>

View File

@@ -0,0 +1,14 @@
<template>
<span class="tooltip">
<span v-html="props.html"></span>
<span class="tooltiptext" v-html="props.tooltip"></span>
</span>
</template>
<script setup>
import { defineAsyncComponent } from 'vue';
var props = defineProps({
html: String,
tooltip: String,
width: Number
})
</script>

171
app/components/TopMenu.vue Normal file
View File

@@ -0,0 +1,171 @@
<template>
<nav class="navbar is-fixed-top has-shadow px-3" role="navigation">
<div class="navbar-brand mr-5">
<span class="navbar-item">
<SvgIcon v-bind="{ name: 'dot.svg', size: 18, type: 'primary' }"</SvgIcon>
<span class="fsb-20 has-text-primary">{{$dayjs().format('DD/MM')}}</span>
</span>
<a class="navbar-item header-logo" @click="changeTab(leftmenu[0])"></a>
<a
role="button"
class="navbar-burger"
id="burger"
aria-label="menu"
aria-expanded="false"
data-target="navMenu"
@click="handleClick()"
>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
<span aria-hidden="true"></span>
</a>
</div>
<div class="navbar-menu" id="navMenu">
<div class="navbar-start" style="min-width: 650px;">
<template v-for="(v, i) in leftmenu" :key="i" :id="v.code">
<a class="navbar-item px-2" v-if="!v.submenu" @click="changeTab(v)">
<span
:class="`fsb-17 ${currentTab.code === v.code ? 'activetab' : ''}`"
style="padding: 3px 4px"
>
{{ v[lang] }}
</span>
</a>
<div class="navbar-item has-dropdown is-hoverable" v-else>
<a class="navbar-item px-2" @click="changeTab(v)">
<span
:class="`icon-text ${currentTab.code === v.code ? 'activetab' : ''}`"
style="padding: 3px 4px"
>
<span class="fsb-16">{{ v[lang] }}</span>
<SvgIcon
style="padding-top: 5px"
v-bind="{ name: 'down2.svg', type: currentTab.code === v.code ? 'white' : 'dark', size: 15 }"
>
</SvgIcon>
</span>
</a>
<div class="navbar-dropdown has-background-light">
<a
class="navbar-item has-background-light fs-15 has-text-black py-1 border-bottom"
v-for="x in v.submenu"
@click="changeTab(v, x)"
>
{{ x[lang] }}
</a>
</div>
</div>
</template>
</div>
<div class="navbar-end">
<a class="navbar-item" @click="changeTab(tabConfig)" v-if="tabConfig">
<SvgIcon v-bind="{ name: 'configuration.svg', type: 'findata', size: 24 }"></SvgIcon>
</a>
<a class="navbar-item" @click="openProfile()" v-if="avatar">
<Avatarbox v-bind="avatar"></Avatarbox>
</a>
</div>
</div>
</nav>
</template>
<script setup>
import { watch } from "vue";
const router = useRouter();
const route = useRoute();
const emit = defineEmits(["changetab", "langChanged"]);
const { $find, $filter, $findIndex, $store } = useNuxtApp();
const lang = ref($store.lang);
var menu = $filter($store.common, { category: "topmenu" });
if($store.rights.length>0) {
menu = menu.filter(v=>$findIndex($store.rights, {setting: v.id})>=0)
}
if(menu.length===0) {
$snackbar($store.lang==='vi'? 'Bạn không có quyền truy cập' : 'You do not have permission to access.')
}
menu.map(v=>{
let arr = $filter($store.common, {category: 'submenu', classify: v.code})
if($store.rights.length>0) {
arr = arr.filter(x=>$findIndex($store.rights, {setting: x.id})>=0)
}
v.submenu = arr.length>0? arr : null
})
var leftmenu = $filter(menu, {category: 'topmenu', classify: 'left'})
var currentTab = ref(leftmenu.length>0? leftmenu[0] : undefined)
var subTab = ref();
var tabConfig = $find(menu, { code: "configuration" });
var avatar = ref();
var isAdmin = ref();
const handleClick = function () {
const target = document.getElementById("burger");
target.classList.toggle("is-active");
const target1 = document.getElementById("navMenu");
target1.classList.toggle("is-active");
};
const closeMenu = function () {
if (!document) return;
const target = document.getElementById("burger");
const target1 = document.getElementById("navMenu");
if (!target) return;
if (target.classList.contains("is-active")) {
target.classList.remove("is-active");
target1.classList.remove("is-active");
}
};
function changeTab(tab, subtab) {
if (tab.submenu && tab.submenu.length > 0 && !subtab && !tab.detail) {
subtab = tab.submenu[0];
}
currentTab.value = tab;
subTab.value = subtab;
emit("changetab", tab, subtab);
closeMenu();
let query = subtab ? { tab: tab.code, subtab: subtab.code } : { tab: tab.code };
router.push({ query: query });
}
function openProfile() {
let modal = { component: "user/Profile", width: "1100px", height: "360px", title: $store.lang==='vi'? 'Thông tin cá nhân' : '"User profile"' };
$store.commit("showmodal", modal);
}
let found = route.query.tab? $find(menu, {code: route.query.tab}) : undefined
if(found || currentTab.value) changeTab(found || currentTab.value)
onMounted(() => {
if (!$store.login) return;
avatar.value = {
image: null,
text: $store.login.fullname.substring(0, 1).toUpperCase(),
size: "two",
type: "findata",
};
isAdmin.value = $store.login.type__code === "admin";
});
watch(
() => $store.login,
(newVal, oldVal) => {
if (!newVal) return;
avatar.value = {
image: null,
text: $store.login.fullname.substring(0, 1).toUpperCase(),
size: "two",
type: "findata",
};
isAdmin.value = $store.login.type__code === "admin";
lang.value = $store.lang;
}
);
</script>
<style scoped>
.navbar-dropdown {
padding-block: 0.375rem;
overflow: hidden;
}
.navbar-dropdown > .navbar-item {
&:hover {
background-color: hsl(30, 48%, 82%) !important;
}
&:last-child {
border-bottom: none;
}
}
</style>

View File

@@ -0,0 +1,79 @@
<template>
<div v-if="record">
<div class="columns is-multiline mx-0 mt-1" id="printable">
<div class="column is-5">
<div class="field">
<label class="label">{{ $lang('code') }}:</label>
<div class="control">
{{ `${record.code}` }}
</div>
</div>
</div>
<div class="column is-7">
<div class="field">
<label class="label">{{ $lang('account-type') }}:</label>
<div class="control">
{{ `${record.type__code} / ${record.type__name}` }}
</div>
</div>
</div>
<div class="column is-5">
<div class="field">
<label class="label">{{ $lang('currency') }}:</label>
<div class="control">
{{ `${record.currency__code} / ${record.currency__name}` }}
</div>
</div>
</div>
<div class="column is-7">
<div class="field">
<label class="label">{{ $lang('balance') }}:</label>
<div class="control">
{{ $numtoString(record.balance) }}
</div>
<!--<p class="help is-findata">{{$vnmoney($formatNumber(record.balance))}}</p>-->
</div>
</div>
<div class="column is-5">
<div class="field">
<label class="label">{{ $lang('open-date') }}:</label>
<div class="control">
{{ `${$dayjs(record.create_time).format('DD/MM/YYYY')}` }}
</div>
</div>
</div>
<!--<div class="column is-7">
<div class="field">
<label class="label">Chi nhánh:</label>
<div class="control">
{{ `${record.branch__code} / ${record.branch__name}` }}
</div>
</div>
</div> -->
</div>
<div class="border-bottom"></div>
<div class="mt-5" id="ignore">
<button class="button is-primary has-text-white" @click="$exportpdf('printable', record.code)">{{$lang('print')}}</button>
</div>
</div>
</template>
<script>
export default {
props: ['row'],
data() {
return {
errors: {},
record: undefined
}
},
async created() {
this.record = await this.$getdata('internalaccount', {id: this.row.account || this.row.id}, undefined, true)
},
methods: {
selected(attr, obj) {
this.record[attr] = obj
if(attr==='_type') this.category = obj.category__code
}
}
}
</script>

View File

@@ -0,0 +1,143 @@
<!-- components/dialog/ConfirmDeleteEntry.vue -->
<template>
<div class="has-text-centered">
<div class=" mb-3 p-3">
<p class="is-size-5 has-text-weight-semibold mb-4">
Bạn chắc chắn muốn xóa bút toán này?
</p>
<p class="mt-3 has-text-danger has-text-weight-semibold">
Hành động này <strong>không thể hoàn tác</strong>.<br>
Dữ liệu liên quan (nếu ) sẽ bị xóa vĩnh viễn.
</p>
</div>
<div class="field is-grouped is-justify-content-center">
<!-- Captcha addon group - shown only when captcha is not confirmed -->
<p class="control" v-if="!isConfirmed">
<div class="field has-addons">
<p class="control">
<input
class="input"
type="text"
placeholder="Nhập mã xác nhận"
v-model="userInputCaptcha"
@keyup.enter="isConfirmed && confirmDelete()"
>
</p>
<p class="control">
<a class="button is-static has-text-weight-bold has-background-grey-lighter"
style="font-family: 'Courier New', monospace; letter-spacing: 2px;">
{{ captchaCode }}
</a>
</p>
<p class="control">
<button class="button" @click="generateCaptcha" title="Tạo mã mới">
<span class="icon">
<SvgIcon name="refresh.svg" type="primary" :size="23" />
</span>
</button>
</p>
</div>
</p>
<!-- Action buttons -->
<!-- Confirm button - shown only when captcha IS confirmed -->
<p class="control" v-if="isConfirmed">
<button
class="button is-danger"
:class="{ 'is-loading': isDeleting }"
:disabled="isDeleting"
@click="confirmDelete"
>
Xác nhận xóa
</button>
</p>
<!-- Cancel button - always shown -->
<p class="control">
<button
class="button"
:disabled="isDeleting"
@click="cancel"
>
Hủy
</button>
</p>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { useNuxtApp } from '#app'
const props = defineProps({
entryId: {
type: [String, Number],
required: true
}
})
const emit = defineEmits(['close', 'deleted'])
const { $snackbar ,$insertapi} = useNuxtApp()
const isDeleting = ref(false)
const captchaCode = ref('')
const userInputCaptcha = ref('')
const isConfirmed = computed(() => {
return userInputCaptcha.value.toLowerCase() === captchaCode.value.toLowerCase() && userInputCaptcha.value !== ''
})
const generateCaptcha = () => {
captchaCode.value = Math.random().toString(36).substring(2, 7).toUpperCase()
userInputCaptcha.value = ''
}
// Initial generation
generateCaptcha()
const confirmDelete = async () => {
if (isDeleting.value || !isConfirmed.value) return
isDeleting.value = true
try {
// Gọi API xóa theo đúng endpoint delete-entry/{id}
const result = await $insertapi('deleteentry', {id: props.entryId})
if (result === 'error' || !result) {
throw new Error('API xóa trả về lỗi')
}
$snackbar(
`Đã xóa bút toán ID ${props.entryId} thành công`,
'Thành công',
'Success'
)
emit('deleted', props.entryId)
emit('close')
} catch (err) {
console.error('Xóa bút toán thất bại:', err)
let errorMsg = 'Không thể xóa bút toán. Vui lòng thử lại.'
// Nếu backend trả về thông báo cụ thể
if (err?.response?.data?.detail) {
errorMsg = err.response.data.detail
} else if (err?.response?.data?.non_field_errors) {
errorMsg = err.response.data.non_field_errors.join(' ')
}
$snackbar(errorMsg, 'Lỗi', 'Danger')
} finally {
isDeleting.value = false
}
}
const cancel = () => {
emit('close')
}
</script>

View File

@@ -0,0 +1,73 @@
<template>
<div class="columns is-multiline mx-0">
<div class="column is-3">
<div class="field">
<label class="label">Từ ngày<b class="ml-1 has-text-danger">*</b></label>
<div class="control">
<Datepicker
v-bind="{ record, attr: 'fdate', maxdate: new Date() }"
@date="selected('fdate', $event)"
></Datepicker>
</div>
<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">Đến ngày<b class="ml-1 has-text-danger">*</b></label>
<div class="control">
<Datepicker
v-bind="{ record, attr: 'tdate', maxdate: new Date() }"
@date="selected('tdate', $event)"
></Datepicker>
</div>
<p class="help is-danger" v-if="errors.issued_date">{{ errors.issued_date }}</p>
</div>
</div>
</div>
<DataView v-bind="vbind" v-if="vbind" />
</template>
<script setup>
const { $dayjs, $id } = useNuxtApp();
const fdate = ref($dayjs().format("YYYY-MM-DD"));
const tdate = ref($dayjs().format("YYYY-MM-DD"));
const record = ref({ fdate: fdate.value, tdate: tdate.value });
const errors = ref({});
const vbind = ref(null);
onMounted(() => {
loadData();
})
function selected(attr, value) {
if (attr === "fdate") fdate.value = value;
else tdate.value = value;
loadData();
}
function loadData() {
vbind.value = undefined;
setTimeout(() => {
vbind.value = {
pagename: `debt-customer-${$id()}`,
setting: "debt-customer",
api: "internalentry",
params: {
values:
"customer__code,customer__fullname,customer__type__name,customer__legal_type__name,customer__legal_code",
distinct_values: {
sum_sale_price: { type: "Sum", field: "product__prdbk__transaction__sale_price" },
sum_received: { type: "Sum", field: "product__prdbk__transaction__amount_received" },
sum_remain: { type: "Sum", field: "product__prdbk__transaction__amount_remain" },
},
summary: "annotate",
filter: {
date__gte: fdate.value,
date__lte: tdate.value
},
sort: "-sum_remain",
},
}
});
}
</script>

View File

@@ -0,0 +1,74 @@
<template>
<div class="columns is-multiline mx-0">
<div class="column is-3">
<div class="field">
<label class="label">Từ ngày<b class="ml-1 has-text-danger">*</b></label>
<div class="control">
<Datepicker
v-bind="{ record, attr: 'fdate', maxdate: new Date() }"
@date="selected('fdate', $event)"
></Datepicker>
</div>
<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">Đến ngày<b class="ml-1 has-text-danger">*</b></label>
<div class="control">
<Datepicker
v-bind="{ record, attr: 'tdate', maxdate: new Date() }"
@date="selected('tdate', $event)"
></Datepicker>
</div>
<p class="help is-danger" v-if="errors.issued_date">{{ errors.issued_date }}</p>
</div>
</div>
</div>
<DataView v-bind="vbind" v-if="vbind" />
</template>
<script>
export default {
data() {
return {
fdate: this.$dayjs().format("YYYY-MM-DD"),
tdate: this.$dayjs().format("YYYY-MM-DD"),
record: {},
errors: {},
vbind: null,
};
},
created() {
this.record = { fdate: this.fdate, tdate: this.tdate };
this.loadData();
},
methods: {
selected(attr, value) {
console.log("===date===", attr, value, this.fdate, this.tdate);
if (attr === "fdate") this.fdate = value;
else this.tdate = value;
this.loadData();
},
loadData() {
this.vbind = undefined;
setTimeout(
() =>
(this.vbind = {
setting: "debt-product-1",
api: "internalentry",
params: {
values:
"product,product__prdbk__transaction__amount_received,product__trade_code,product__prdbk__transaction__sale_price,product__zone_type__name,customer,customer__code,customer__fullname",
distinct_values: {
sumCR: { type: "Sum", filter: { type__code: "CR" }, field: "amount" },
sumDR: { type: "Sum", filter: { type__code: "DR" }, field: "amount" },
},
summary: "annotate",
filter: { date__gte: this.fdate, date__lte: this.tdate },
},
})
);
},
},
};
</script>

View File

@@ -0,0 +1,581 @@
<template>
<div class="p-3">
<!-- TimeOption Filter -->
<TimeOption
v-bind="{
pagename: 'debt_report',
api: 'transaction',
timeopt: { time: 36000, disable: ['add'] },
filter: { phase: 3 }
}"
@option="handleTimeOption"
@excel="exportExcel"
@refresh-data="loadData"
class="mb-3"
/>
<!-- Loading -->
<div v-if="loading" class="has-text-centered py-6">
<p class="has-text-grey mb-3">Đang tải dữ liệu...</p>
<progress class="progress is-small is-primary" max="100"></progress>
</div>
<div class="" v-else>
<!-- Table -->
<div
v-if="filteredRows.length > 0"
class="table-container"
style="overflow-x: auto; max-width: 100%;"
>
<table class="table is-bordered is-striped is-narrow is-hoverable is-fullwidth debt-table">
<thead>
<tr>
<!-- Fixed columns (sticky left) -->
<th
rowspan="2"
class="fixed-col has-background-primary has-text-white has-text-centered"
>
STT
</th>
<th rowspan="2" class="fixed-col has-background-primary has-text-white">
Mã KH
</th>
<th rowspan="2" class="fixed-col has-background-primary has-text-white">
Mã Căn
</th>
<th rowspan="2" class="fixed-col has-background-primary has-text-white">
Ngày ký HĐ
</th>
<th rowspan="2" class="fixed-col has-background-primary has-text-white">
Giá trị HĐMB
</th>
<th rowspan="2" class="fixed-col has-background-primary has-text-white">
Tiền nộp theo HĐV/TTTHNV
</th>
<th rowspan="2" class="fixed-col has-background-primary has-text-white">
Tỷ lệ
</th>
<!-- Scrollable columns -->
<th
v-for="(sch, si) in scheduleHeaders"
:key="si"
:colspan="6"
class="has-text-centered has-background-primary has-text-white"
>
{{ sch.label }}
</th>
<th rowspan="2" class="has-background-primary has-text-white">
Số tiền quá hạn
</th>
</tr>
<tr>
<template v-for="(sch, si) in scheduleHeaders" :key="si">
<th class="has-background-primary has-text-white sub-header">Ngày</th>
<th class="has-background-primary has-text-white sub-header">Số tiền</th>
<th class="has-background-primary has-text-white sub-header">Lũy kế sang đợt</th>
<th class="has-background-primary has-text-white sub-header">Số tiền đã thực thanh toán</th>
<th class="has-background-primary has-text-white sub-header">Tỷ lệ</th>
<th class="has-background-primary has-text-white sub-header-remain">Dư nợ</th>
</template>
</tr>
</thead>
<tbody>
<tr v-for="(row, ri) in filteredRows" :key="ri">
<!-- Fixed columns -->
<td class="fixed-col has-text-centered">{{ ri + 1 }}</td>
<td class="fixed-col">{{ row.customer_code }}</td>
<td class="fixed-col has-text-weight-semibold has-text-primary">{{ row.trade_code }}</td>
<td class="fixed-col">{{ row.contract_date }}</td>
<td class="fixed-col has-text-right">{{ fmt(row.sale_price) }}</td>
<td class="fixed-col has-text-right has-text-weight-semibold has-background-warning-light">
{{ fmt(row.ttthnv_paid) }}
</td>
<td class="fixed-col has-text-right has-background-success-light">
{{ pct(row.ttthnv_paid, row.sale_price) }}
</td>
<!-- Scrollable columns -->
<template v-for="(sch, si) in scheduleHeaders" :key="si">
<template v-if="row.schedules[si]">
<td>{{ row.schedules[si].to_date }}</td>
<td class="has-text-right">{{ fmt(row.schedules[si].amount) }}</td>
<td class="has-text-right has-text-info">{{ fmt(row.schedules[si].luy_ke_sang_dot) }}</td>
<td class="has-text-right has-text-weight-semibold has-text-success">
{{ fmt(row.schedules[si].thuc_thanh_toan) }}
</td>
<td
class="has-text-right"
:class="pctClass(row.schedules[si].thuc_thanh_toan, row.schedules[si].amount)"
>
{{ pct(row.schedules[si].thuc_thanh_toan, row.schedules[si].amount) }}
</td>
<td
class="has-text-right has-text-weight-semibold"
:class="Number(row.schedules[si].amount_remain) > 0 ? 'has-text-danger' : 'has-text-success'"
>
{{ fmt(row.schedules[si].amount_remain) }}
</td>
</template>
<template v-else>
<td colspan="6" class="has-text-centered has-text-grey-light" style="font-style: italic">—</td>
</template>
</template>
<td
class="has-text-right has-text-weight-bold"
:class="Number(row.overdue) > 0 ? 'has-background-danger-light' : ''"
>
{{ Number(row.overdue) > 0 ? fmt(row.overdue) : '—' }}
</td>
</tr>
</tbody>
<tfoot>
<tr class="has-background-light">
<td :colspan="7" class="fixed-col has-text-right has-text-weight-bold">TỔNG CỘNG:</td>
<template v-for="(sch, si) in scheduleHeaders" :key="si">
<td></td>
<td class="has-text-right has-text-weight-semibold">{{ fmt(colSum(si, 'amount')) }}</td>
<td></td>
<td class="has-text-right has-text-weight-semibold has-text-success">
{{ fmt(colSum(si, 'thuc_thanh_toan')) }}
</td>
<td></td>
<td class="has-text-right has-text-weight-semibold has-text-danger">
{{ fmt(colSum(si, 'amount_remain')) }}
</td>
</template>
<td class="has-text-right has-text-weight-bold has-text-danger">
{{ fmt(totalOverdue) }}
</td>
</tr>
</tfoot>
</table>
</div>
<!-- Empty -->
<div v-else class="has-text-centered py-6">
<p class="has-text-grey">Không dữ liệu</p>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import TimeOption from '~/components/datatable/TimeOption'
const { $findapi, $getapi, $dayjs, $copy } = useNuxtApp()
const loading = ref(false)
const rows = ref([])
const filteredRows = ref([])
const scheduleHeaders = ref([])
const currentFilter = ref(null)
const currentSearch = ref(null)
function handleTimeOption(option) {
if (!option) {
currentFilter.value = null
currentSearch.value = null
applyFilters()
return
}
if (option.filter) {
currentFilter.value = option.filter
currentSearch.value = null
applyFilters()
} else if (option.filter_or) {
currentFilter.value = null
currentSearch.value = option.filter_or
applyFilters()
}
}
function applyFilters() {
let filtered = [...rows.value]
if (currentFilter.value && currentFilter.value.create_time__date__gte) {
const filterDate = new Date(currentFilter.value.create_time__date__gte)
filtered = filtered.filter(row => {
const contractDate = row.contract_date_raw ? new Date(row.contract_date_raw) : null
return contractDate && contractDate >= filterDate
})
}
if (currentSearch.value) {
const searchTerms = Object.values(currentSearch.value).map(v =>
String(v).toLowerCase().replace('__icontains', '')
)
filtered = filtered.filter(row => {
const searchableText = [
row.customer_code,
row.customer_name,
row.trade_code,
row.contract_date,
String(row.sale_price)
].join(' ').toLowerCase()
return searchTerms.some(term => searchableText.includes(term))
})
}
filteredRows.value = filtered
}
async function loadData() {
loading.value = true
rows.value = []
filteredRows.value = []
scheduleHeaders.value = []
try {
const txnConn = $copy($findapi('transaction'))
txnConn.params = {
filter: { phase: 3 },
values: 'id,code,date,customer,customer__code,customer__fullname,sale_price,amount_received,amount_remain,product,product__trade_code,phase',
sort: 'id'
}
const detailConn = $copy($findapi('reservation'))
detailConn.params = {
filter: { transaction__phase: 3, phase: 3 },
values: 'id,transaction,phase,amount,amount_received,amount_remaining,status',
sort: 'transaction'
}
const schConn = $copy($findapi('payment_schedule'))
schConn.params = {
filter: { txn_detail__phase: 3 },
values:
'id,code,cycle,to_date,from_date,amount,paid_amount,amount_remain,remain_amount,status,status__name,txn_detail,txn_detail__transaction,txn_detail__phase,txn_detail__amount_received',
sort: 'txn_detail__transaction,cycle'
}
const ttthnvConn = $copy($findapi('reservation'))
ttthnvConn.params = {
filter: { transaction__phase: 3, phase: 4 },
values: 'id,transaction,phase,amount,amount_received,amount_remaining,status',
sort: 'transaction'
}
const [txnRs, detailRs, schRs, ttthnvRs] = await $getapi([
txnConn,
detailConn,
schConn,
ttthnvConn
])
const transactions = txnRs?.data?.rows || []
const details = detailRs?.data?.rows || []
const schedules = schRs?.data?.rows || []
const ttthnvList = ttthnvRs?.data?.rows || []
if (!transactions.length) {
loading.value = false
return
}
// TTTHNV map
const ttthnvMap = {}
ttthnvList.forEach(t => {
const tid = t.transaction
ttthnvMap[tid] = (ttthnvMap[tid] || 0) + Number(t.amount_received || 0)
})
// Group schedules by transaction
const schByTxn = {}
schedules.forEach(s => {
const tid = s.txn_detail__transaction
if (!schByTxn[tid]) schByTxn[tid] = []
schByTxn[tid].push(s)
})
// Tìm số đợt tối đa
let maxCycles = 0
Object.values(schByTxn).forEach(list => {
const paymentList = list.filter(s => Number(s.cycle) > 0)
if (paymentList.length > maxCycles) maxCycles = paymentList.length
})
scheduleHeaders.value = Array.from({ length: maxCycles }, (_, i) => ({
label: `L0${i + 1}`,
index: i
}))
rows.value = transactions.map(txn => {
const txnSchedules = (schByTxn[txn.id] || [])
.filter(s => Number(s.cycle) > 0)
.sort((a, b) => Number(a.cycle) - Number(b.cycle))
const ttthnvPaid = ttthnvMap[txn.id] || 0
const salePriceNum = Number(txn.sale_price || 0)
// ───────────────────────────────────────────────
// QUAN TRỌNG: Theo yêu cầu mới nhất của bạn
// Lũy kế HĐCN = TTTHNV
const luyKe = ttthnvPaid
// ───────────────────────────────────────────────
// Phân bổ TTTHNV dần vào từng đợt → tính lũy kế sang đợt
let remainingTTTHNV = ttthnvPaid
const schedulesWithCalc = txnSchedules.map(sch => {
const scheduleAmount = Number(sch.amount || 0)
// Lũy kế sang đợt = min(remaining TTTHNV, số tiền đợt)
const luyKeSangDot = Math.min(remainingTTTHNV, scheduleAmount)
// Số tiền đã thực thanh toán = paid_amount - lũy kế sang đợt
const paidAmountFromSchedule = Number(sch.paid_amount || 0)
const thucThanhToan = Math.max(0, paidAmountFromSchedule - luyKeSangDot)
// Dư nợ = số tiền đợt - lũy kế sang đợt - thực thanh toán
const amountRemain = Math.max(0, scheduleAmount - luyKeSangDot - thucThanhToan)
remainingTTTHNV -= luyKeSangDot
remainingTTTHNV = Math.max(0, remainingTTTHNV)
return {
to_date: sch.to_date ? $dayjs(sch.to_date).format('DD/MM/YYYY') : '—',
amount: scheduleAmount,
luy_ke_sang_dot: luyKeSangDot,
thuc_thanh_toan: thucThanhToan,
amount_remain: amountRemain,
status: sch.status
}
})
// Tính quá hạn
const todayDate = new Date()
const overdue = txnSchedules.reduce((sum, sch, idx) => {
const toDate = sch.to_date ? new Date(sch.to_date) : null
const remain = schedulesWithCalc[idx]?.amount_remain || 0
if (toDate && toDate < todayDate && remain > 0) {
return sum + remain
}
return sum
}, 0)
const paddedSchedules = Array.from({ length: maxCycles }, (_, i) => schedulesWithCalc[i] || null)
return {
customer_code: txn.customer__code || '',
customer_name: txn.customer__fullname || '',
trade_code: txn.product__trade_code || txn.code || '',
contract_date: txn.date ? $dayjs(txn.date).format('DD/MM/YYYY') : '—',
contract_date_raw: txn.date,
sale_price: salePriceNum,
ttthnv_paid: ttthnvPaid,
luy_ke: luyKe, // ← chính là TTTHNV
schedules: paddedSchedules,
overdue: overdue
}
})
filteredRows.value = rows.value
} catch (e) {
console.error('BaoCaoCongNo error:', e)
} finally {
loading.value = false
}
}
function fmt(val) {
const n = Number(val)
if (isNaN(n) || (!n && n !== 0)) return '—'
return n.toLocaleString('vi-VN')
}
function pct(num, denom) {
const n = Number(num)
const d = Number(denom)
if (!d || isNaN(n)) return '—'
return (n / d * 100).toFixed(1) + '%'
}
function pctClass(paid, amount) {
const p = Number(paid)
const a = Number(amount)
if (isNaN(p) || isNaN(a) || !a) return ''
const ratio = p / a
if (ratio >= 1) return 'has-text-success'
if (ratio >= 0.5) return 'has-text-info'
return 'has-text-danger'
}
function colSum(scheduleIndex, field) {
return filteredRows.value.reduce((sum, row) => {
const sch = row.schedules[scheduleIndex]
return sum + (sch ? Number(sch[field] || 0) : 0)
}, 0)
}
const totalOverdue = computed(() =>
filteredRows.value.reduce((s, r) => s + Number(r.overdue || 0), 0)
)
function exportExcel() {
const headers = [
'STT',
'Mã KH',
'Mã Căn',
'Ngày ký HĐ',
'Giá trị HĐMB',
'Tiền nộp TTTHNV',
'Lũy kế tiền về HĐCN',
'Tỷ lệ HĐCN'
]
scheduleHeaders.value.forEach(h => {
headers.push(
`${h.label} - Ngày`,
`${h.label} - Số tiền`,
`${h.label} - Lũy kế sang`,
`${h.label} - Số tiền đã thực thanh toán`,
`${h.label} - Tỷ lệ`,
`${h.label} - Dư nợ`
)
})
headers.push('Số tiền quá hạn')
const data = filteredRows.value.map((row, i) => {
const base = [
i + 1,
row.customer_code,
row.trade_code,
row.contract_date,
fmt(row.sale_price),
fmt(row.ttthnv_paid),
fmt(row.luy_ke),
pct(row.luy_ke, row.sale_price)
]
scheduleHeaders.value.forEach((_, si) => {
const sch = row.schedules[si]
if (sch) {
base.push(
sch.to_date,
fmt(sch.amount),
fmt(sch.luy_ke_sang_dot),
fmt(sch.thuc_thanh_toan),
pct(sch.thuc_thanh_toan, sch.amount),
fmt(sch.amount_remain)
)
} else {
base.push('', '', '', '', '', '')
}
})
base.push(fmt(row.overdue))
return base
})
const csvRows = [headers, ...data]
const csv = csvRows.map(r => r.map(c => `"${String(c ?? '').replace(/"/g, '""')}"`).join(',')).join('\n')
const BOM = '\uFEFF'
const blob = new Blob([BOM + csv], { type: 'text/csv;charset=utf-8;' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `bao-cao-cong-no-${$dayjs().format('YYYYMMDD')}.csv`
a.click()
URL.revokeObjectURL(url)
}
onMounted(() => loadData())
</script>
<style scoped>
/* Fixed columns style */
.fixed-col {
position: sticky;
left: 0;
z-index: 3;
background-color: white; /* nền trắng cho body */
min-width: 80px; /* điều chỉnh theo nhu cầu */
}
/* Cột STT thường hẹp hơn */
.fixed-col:nth-child(1) {
left: 0;
min-width: 50px;
z-index: 4; /* cao hơn để đè lên */
}
/* Cột Mã KH */
.fixed-col:nth-child(2) {
left: 50px;
min-width: 100px;
}
/* Cột Mã Căn */
.fixed-col:nth-child(3) {
left: 150px;
min-width: 120px;
}
/* Cột Ngày ký HĐ */
.fixed-col:nth-child(4) {
left: 270px;
min-width: 110px;
}
/* Cột Giá trị HĐMB */
.fixed-col:nth-child(5) {
left: 380px;
min-width: 140px;
}
/* Cột Tiền nộp TTTHNV */
.fixed-col:nth-child(6) {
left: 520px;
min-width: 160px;
}
/* Cột Lũy kế HĐCN */
.fixed-col:nth-child(7) {
left: 680px;
min-width: 160px;
}
/* Cột Tỷ lệ */
.fixed-col:nth-child(8) {
left: 840px;
min-width: 90px;
border-right: 2px solid #dee2e6 !important; /* tạo đường phân cách rõ ràng */
}
/* Header fixed */
.debt-table thead .fixed-col {
position: sticky;
top: 0;
z-index: 5;
background-color: #204853 !important; /* primary color */
color: white !important;
}
/* Footer fixed columns */
.debt-table tfoot .fixed-col {
background-color: #f8f9fa !important;
font-weight: bold;
}
/* Đảm bảo tfoot cũng có z-index */
.debt-table tfoot tr {
z-index: 2;
}
/* Optional: bóng nhẹ khi scroll để dễ nhận biết fixed */
.fixed-col {
box-shadow: 2px 0 5px rgba(0, 0, 0, 0.08);
}
</style>

View File

@@ -0,0 +1,31 @@
<template>
<div>
<DataView v-bind="{api: 'internalaccount', setting: store.lang==='en'? 'internal-account-en' : 'internal-account', pagename: pagename,
modal: {title: 'Tài khoản', component: 'accounting/AccountView', width: '50%', 'height': '300px'}}" />
<Modal @close="showmodal=undefined" v-bind="showmodal" v-if="showmodal" />
</div>
</template>
<script>
import { useStore } from '~/stores/index'
export default {
setup() {
const store = useStore()
return {store}
},
data() {
return {
showmodal: undefined,
pagename: 'pagedata32'
}
},
methods: {
deposit() {
this.showmodal = {component: 'accounting/InternalDeposit', title: 'Nộp tiền tài khoản nội bộ', width: '40%', height: '300px',
vbind: {pagename: this.pagename}}
},
doClick() {
this.$approvalcode()
}
}
}
</script>

View File

@@ -0,0 +1,228 @@
<template>
<div>
<div class="columns is-multiline mx-0">
<div class="column is-8">
<div class="field">
<label class="label">{{ $lang('account') }}<b class="ml-1 has-text-danger">*</b></label>
<div class="control">
<SearchBox v-bind="{api:'internalaccount', field:'label', column:['label'], first: true, optionid: record.account}"
:disabled="record.account" @option="selected('_account', $event)" v-if="!record.id"></SearchBox>
<span v-else>{{record.account__code}}</span>
</div>
<p class="help is-danger" v-if="errors._account">{{ errors._account }}</p>
</div>
</div>
<div class="column is-4">
<div class="field">
<label class="label"
>Ngày hạch toán<b class="ml-1 has-text-danger">*</b></label
>
<div class="control">
<Datepicker
v-bind="{ record: record, attr: 'date', maxdate: new Date()}"
@date="selected('date', $event)"
></Datepicker>
</div>
<p class="help is-danger" v-if="errors.issued_date">{{ errors.issued_date }}</p>
</div>
</div>
<div class="column is-4">
<div class="field">
<label class="label">Thu / chi<b class="ml-1 has-text-danger">*</b></label>
<div class="control">
<SearchBox v-bind="{api:'entrytype', field:'name', column:['name'], first: true, optionid: record.type}"
:disabled="record.type" @option="selected('_type', $event)" v-if="!record.id"></SearchBox>
<span v-else>{{record.type__name}}</span>
</div>
<p class="help is-danger" v-if="errors._type">{{ errors._type }}</p>
</div>
</div>
<div class="column is-4">
<div class="field">
<label class="label">{{$lang('amount-only')}}<b class="ml-1 has-text-danger">*</b></label>
<div class="control">
<InputNumber v-bind="{record: record, attr: 'amount', placeholder: ''}" @number="selected('amount', $event)"></InputNumber>
</div>
<p class="help is-danger" v-if="errors.amount">{{errors.amount}}</p>
</div>
</div>
<div class="column is-4">
<div class="field">
<label class="label">Phương thức thanh toán<b class="ml-1 has-text-danger">*</b></label>
<div class="control">
<SearchBox v-bind="{api:'entrycategory', field:'name', column:['name'], first: true, optionid: record.type}"
:disabled="record.type" @option="selected('_category', $event)" v-if="!record.id"></SearchBox>
<span v-else>{{record.type__name}}</span>
</div>
<p class="help is-danger" v-if="errors._type">{{ errors._type }}</p>
</div>
</div>
<div class="column is-6">
<div class="field">
<label class="label">Sản phẩm<b class="ml-1 has-text-danger">*</b></label>
<div class="control">
<SearchBox v-bind="{
api: 'product',
field: 'label',
searchfield: ['code', 'trade_code', 'type__name', 'land_lot_size', 'zone_type__name'],
column: ['code', 'trade_code', 'type__name', 'land_lot_size', 'zone_type__name'],
first: true
}" @option="selected('product', $event)" />
</div>
<p class="help is-danger" v-if="errors._type">{{ errors._type }}</p>
</div>
</div>
<div class="column is-6">
<div class="field">
<label class="label">Khách hàng<b class="ml-1 has-text-danger">*</b></label>
<div class="control">
<SearchBox v-bind="{
api: 'customer',
field: 'label',
searchfield: ['code', 'fullname', 'phone', 'legal_code'],
column: ['code', 'fullname', 'phone', 'legal_code'],
first: true
}" @option="selected('customer', $event)" />
</div>
<p class="help is-danger" v-if="errors._type">{{ errors._type }}</p>
</div>
</div>
<div class="column is-12">
<div class="field">
<label class="label">{{ $lang('content') }}<b class="ml-1 has-text-danger">*</b></label>
<div class="control">
<textarea class="textarea" rows="2" v-model="record.content"></textarea>
</div>
<p class="help is-danger" v-if="errors.content">{{errors.content}}</p>
</div>
</div>
<div class="column is-4">
<div class="field">
<label class="label">Mã tham chiếu</label>
<div class="control">
<input class="input has-text-black" type="text" placeholder="Tối đa 30 ký tự" v-model="record.ref">
</div>
</div>
</div>
<div class="column is-12" v-if="entry">
<div class="field">
<label class="label">Chứng từ đi kèm (nếu có)</label>
<div class="control">
<FileGallery v-bind="{row: entry, pagename: pagename, api: 'entryfile', info: false}"></FileGallery>
</div>
</div>
</div>
</div>
<div class="mt-5 ml-3" v-if="!entry">
<button
:class="[
'button is-primary has-text-white mr-2',
isUpdating && 'is-loading'
]"
@click="confirm()">{{$lang('confirm')}}</button>
</div>
<Modal @close="showContractModal=undefined" v-bind="showContractModal" v-if="showContractModal"></Modal>
<Modal @close="showmodal=undefined" v-bind="showmodal" @confirm="update()" v-if="showmodal"></Modal>
</div>
</template>
<script>
import { useStore } from '~/stores/index'
export default {
setup() {
const store = useStore()
return {store}
},
props: ['pagename', 'row', 'option'],
data() {
return {
record: {date: this.$dayjs().format('YYYY-MM-DD')},
errors: {},
isUpdating: false,
showmodal: undefined,
showContractModal: undefined,
entry: undefined
}
},
created() {
if(!this.option) return
this.record.account = this.option.account
this.record.type = this.option.type
},
methods: {
selected(attr, obj) {
this.record[attr] = obj
this.record = this.$copy(this.record)
},
checkError() {
this.errors = {}
if(this.$empty(this.record._account)) this.errors._account = 'Chưa chọn tài khoản'
if(this.$empty(this.record._type)) this.errors._type = 'Chưa chọn loại hạch toán'
if(this.$empty(this.record.amount)) this.errors.amount = 'Chưa nhập số tiền'
else if(this.$formatNumber(this.record.amount)<=0) this.errors.amount = 'Số tiền phải > 0'
if(this.$empty(this.record.content)) this.errors.content = 'Chưa nhập nội dung'
if(Object.keys(this.errors).length>0) return true
if(this.record._type.code==='DR' && (this.record._account.balance<this.$formatNumber(this.record.amount))) {
this.errors._account = 'Số tài khoản không đủ để trích nợ'
}
return Object.keys(this.errors).length>0
},
confirm() {
if(this.checkError()) return
this.showmodal = {component: `dialog/Confirm`,vbind: {content: this.$lang('confirm-action'), duration: 10},
title: this.$lang('confirm'), width: '500px', height: '100px'}
},
async update() {
this.isUpdating = true;
let obj1 = {
code: this.record._account.code,
amount: this.record.amount,
content: this.record.content,
type: this.record._type.code,
category: this.record._category ? this.record._category.id : 1, user: this.store.login.id,
ref: this.row ? this.row.code : (!this.$empty(this.record.ref) ? this.record.ref.trim() : null),
customer: this.record.customer ? this.record.customer.id : null,
product: this.record.product ? this.record.product.id : null,
date: this.$empty(this.record.date) ? null : this.record.date
}
let rs1 = await this.$insertapi('accountentry', obj1, undefined, false)
if(rs1==='error') return
if (this.record._category.id === 2) {
const genDoc = await this.$generateDocument({
doc_code: 'PHIEU_THU_TIEN_MAT',
entry_id: rs1.id,
output_filename: `PHIEU_THU_TIEN_MAT-${rs1.code}`
});
await this.$insertapi('file', {
name: genDoc.data.pdf,
user: this.store.login.id,
type: 4,
size: 1000,
file: genDoc.data.pdf // or genDoc.data.pdf
});
this.showContractModal = {
component: "application/Contract",
title: "Phiếu thu tiền mặt",
width: "95%",
height: "95vh",
vbind: {
directDocument: genDoc.data
},
}
}
this.entry = rs1
if(this.pagename) {
let data = await this.$getdata('internalaccount', {code__in: [this.record._account.code]})
this.$updatepage(this.pagename, data)
}
this.isUpdating = false;
this.$emit('modalevent', {name: 'entry', data: rs1})
this.$dialog(`Hạch toán <b>${this.record._type.name}</b> số tiền <b>${this.$numtoString(this.record.amount)}</b> vào tài khoản <b>${this.record._account.code}</b> thành công.`, 'Thành công', 'Success', 10)
}
}
}
</script>

View File

@@ -0,0 +1,247 @@
<template>
<div v-if="record" id="printable">
<Caption v-bind="{ title: $lang('info') }" />
<div class="columns is-multiline is-2 m-0">
<div class="column is-3">
<div class="field">
<label class="label">{{ $lang('code') }}:</label>
<div class="control">
<span>{{ record.code }}</span>
</div>
<p class="help is-danger" v-if="errors.type">{{ errors.type }}</p>
</div>
</div>
<div class="column is-3">
<div class="field">
<label class="label">Tài khoản:</label>
<div class="control">
<span>{{ record.account__code }}</span>
</div>
<p class="help is-danger" v-if="errors.type">{{ errors.type }}</p>
</div>
</div>
<div class="column is-3">
<div class="field">
<label class="label">Ngày hạch toán:</label>
<div class="control">
{{ $dayjs(record.date).format('DD/MM/YYYY') }}
</div>
</div>
</div>
<div class="column is-3">
<div class="field">
<label class="label">{{ $lang('amount-only') }}:</label>
<div class="control">
{{ $numtoString(record.amount) }}
</div>
</div>
</div>
<div class="column is-3">
<div class="field">
<label class="label">Thu / chi:</label>
<div class="control">
{{ record.type__name }}
</div>
</div>
</div>
<div class="column is-3">
<div class="field">
<label class="label"> trước:</label>
<div class="control">
{{ $numtoString(record.balance_before) }}
</div>
</div>
</div>
<div class="column is-3">
<div class="field">
<label class="label"> sau:</label>
<div class="control">
{{ $numtoString(record.balance_after) }}
</div>
</div>
</div>
<div class="column is-3">
<div class="field">
<label class="label"> sản phẩm:</label>
<div class="control">
{{ record.product__trade_code }}
</div>
</div>
</div>
<div class="column is-3">
<div class="field">
<label class="label"> khách hàng:</label>
<div class="control">
{{ record.customer__code }}
</div>
</div>
</div>
<div class="column is-3">
<div class="field">
<label class="label">Người hạch toán:</label>
<div class="control">
{{ `${record.inputer__fullname}` }}
</div>
</div>
</div>
<div class="column is-3">
<div class="field">
<label class="label">{{ $lang('time') }}:</label>
<div class="control">
{{ `${$dayjs(record.create_time).format('DD/MM/YYYY')}` }}
</div>
</div>
</div>
<div class="column is-3">
<div class="field">
<label class="label">Ref:</label>
<div class="control">
{{ `${record.ref || '/'}` }}
</div>
</div>
</div>
<div class="column is-8">
<div class="field">
<label class="label">{{ $lang('content') }}:</label>
<div class="control">
{{ `${record.content}` }}
</div>
</div>
</div>
</div>
<!-- PHẦN THÔNG TIN PHÂN BỔ -->
<Caption v-bind="{ title: 'Thông tin phân bổ' }" />
<!-- BẢNG CHI TIẾT PHÂN BỔ -->
<div v-if="record.allocation_detail && record.allocation_detail.length > 0" class="mt-4">
<div class="table-container">
<table class="table is-fullwidth is-striped is-hoverable is-bordered">
<thead>
<tr class="">
<th class="has-background-primary has-text-white has-text-centered">STT</th>
<th class="has-background-primary has-text-white has-text-centered"> lịch</th>
<th class="has-background-primary has-text-white has-text-centered">Loại</th>
<th class="has-background-primary has-text-white has-text-centered">Tổng phân bổ</th>
<th class="has-background-primary has-text-white has-text-centered">Gốc</th>
<th class="has-background-primary has-text-white has-text-centered">Phạt</th>
<th class="has-background-primary has-text-white has-text-centered">Miễn lãi</th>
<th class="has-background-primary has-text-white has-text-centered">Ngày phân bổ</th>
</tr>
</thead>
<tbody>
<tr v-for="(item, index) in record.allocation_detail" :key="index">
<td class="has-text-centered">{{ index + 1 }}</td>
<td>
<span class="tag is-link is-light">{{ item.schedule_code || item.schedule_id }}</span>
</td>
<td class="has-text-centered">
<span v-if="item.type === 'REDUCTION'" class="tag is-warning">Miễn lãi</span>
<span v-else class="tag is-success">Thanh toán</span>
</td>
<td class="has-text-right">
<strong>{{ $numtoString(item.amount) }}</strong>
</td>
<td class="has-text-right">
<span v-if="item.principal" class="has-text-info has-text-weight-semibold">
{{ $numtoString(item.principal) }}
</span>
<span v-else class="has-text-grey-light">-</span>
</td>
<td class="has-text-right">
<span v-if="item.penalty" class="has-text-danger has-text-weight-semibold">
{{ $numtoString(item.penalty) }}
</span>
<span v-else class="has-text-grey-light">-</span>
</td>
<td class="has-text-right">
<span v-if="item.penalty" class="has-text-danger has-text-weight-semibold">
{{ $numtoString(item.penalty_reduce) }}
</span>
<span v-else class="has-text-grey-light">-</span>
</td>
<td class="has-text-centered">{{ $dayjs(item.date).format('DD/MM/YYYY HH:mm:ss') }}</td>
</tr>
</tbody>
<tfoot>
<tr class="has-background-light">
<td colspan="3" class="has-text-right has-text-weight-bold">Tổng cộng:</td>
<td class="has-text-right has-text-weight-bold">{{ $numtoString(totalAllocated) }}</td>
<td class="has-text-right has-text-weight-bold has-text-info">{{
$numtoString(totalPrincipal) }}</td>
<td class="has-text-right has-text-weight-bold has-text-danger">{{
$numtoString(totalPenalty) }}</td>
<td class="has-text-right has-text-weight-bold has-text-danger">{{
$numtoString(totalPenaltyReduce) }}</td>
<td></td>
</tr>
</tfoot>
</table>
</div>
</div>
<div v-else class="notification is-info is-light mt-4">
<p class="has-text-centered">Chưa dữ liệu phân bổ cho bút toán này.</p>
</div>
<Caption class="mt-5 " v-bind="{ title: 'Chứng từ' }"></Caption>
<FileGallery v-bind="{ row: record, api: 'entryfile' }"></FileGallery>
<div class="mt-5" id="ignore">
<button class="button is-primary has-text-white mr-2" @click="$exportpdf('printable', record.code, 'a4', 'landscape')">{{ $lang('print') }}</button>
<button v-if="record.category === 2" class="button is-light" @click="viewPhieuThuTienMat">Xem phiếu thu</button>
</div>
</div>
</template>
<script>
export default {
props: ['row'],
data() {
return {
errors: {},
record: undefined
}
},
async created() {
this.record = await this.$getdata('internalentry', { code: this.row.code }, undefined, true)
},
computed: {
// Tính tổng số tiền đã phân bổ
totalAllocated() {
if (!this.record || !this.record.allocation_detail) return 0
return this.record.allocation_detail.reduce((sum, item) => sum + (item.amount || 0), 0)
},
// Tính tổng gốc
totalPrincipal() {
if (!this.record || !this.record.allocation_detail) return 0
return this.record.allocation_detail.reduce((sum, item) => sum + (item.principal || 0), 0)
},
// Tính tổng phạt
totalPenalty() {
if (!this.record || !this.record.allocation_detail) return 0
return this.record.allocation_detail.reduce((sum, item) => sum + (item.penalty || 0), 0)
},
// Tính tổng phạt đã giảm
totalPenaltyReduce() {
if (!this.record || !this.record.allocation_detail) return 0
return this.record.allocation_detail.reduce((sum, item) => sum + (item.penalty_reduce || 0), 0)
}
},
methods: {
selected(attr, obj) {
this.record[attr] = obj
this.record = this.$copy(this.record)
if (attr === '_type') this.category = obj.category__code
},
viewPhieuThuTienMat() {
const url = `${this.$getpath()}static/contract/PHIEU_THU_TIEN_MAT-${this.record.code}.pdf`;
window.open(url, '_blank');
}
}
}
</script>
<style scoped>
.column {
padding-inline: 0;
}
</style>

View File

@@ -0,0 +1,104 @@
<template>
<div>
<div class="columns is-multiline mx-0">
<div class="column is-12">
<div class="field">
<label class="label">{{$lang('source-account')}}<b class="ml-1 has-text-danger">*</b></label>
<div class="control">
<SearchBox v-bind="{api:'internalaccount', field:'label', column:['label'], first: true, optionid: row.id}"
@option="selected('_source', $event)" v-if="!record.id"></SearchBox>
<span v-else>{{record.account__code}}</span>
</div>
<p class="help is-danger" v-if="errors.source">{{ errors.source }}</p>
</div>
</div>
<div class="column is-12">
<div class="field">
<label class="label">{{$lang('dest-account')}}<b class="ml-1 has-text-danger">*</b></label>
<div class="control">
<SearchBox v-bind="vbind" @option="selected('_target', $event)" v-if="vbind"></SearchBox>
<span v-else>{{record.account__code}}</span>
</div>
<p class="help is-danger" v-if="errors.target">{{ errors.target }}</p>
</div>
</div>
<div class="column is-12">
<div class="field">
<label class="label">{{ $lang('amount-only') }}<b class="ml-1 has-text-danger">*</b></label>
<div class="control">
<InputNumber v-bind="{record: record, attr: 'amount', placeholder: ''}" @number="selected('amount', $event)"></InputNumber>
</div>
<p class="help is-danger" v-if="errors.amount">{{errors.amount}}</p>
</div>
</div>
<div class="column is-12">
<div class="field">
<label class="label">{{ $lang('content') }}<b class="ml-1 has-text-danger">*</b></label>
<div class="control">
<textarea class="textarea" rows="2" v-model="record.content"></textarea>
</div>
<p class="help is-danger" v-if="errors.content">{{errors.content}}</p>
</div>
</div>
</div>
<div class="mt-5">
<button class="button is-primary has-text-white" @click="confirm()">{{ $lang('confirm') }}</button>
</div>
<Modal @close="showmodal=undefined" v-bind="showmodal" @confirm="update()" v-if="showmodal"></Modal>
</div>
</template>
<script>
export default {
props: ['pagename', 'row'],
data() {
return {
record: {},
errors: {},
showmodal: undefined,
vbind: undefined
}
},
methods: {
selected(attr, obj) {
this.record[attr] = obj
this.record = this.$copy(this.record)
if(attr==='_source') {
let currency = obj? obj.currency : undefined
this.vbind = undefined
setTimeout(()=>this.vbind = {api:'internalaccount', field:'label', column:['label'], first: true, filter: {currency: currency}})
}
},
checkError() {
this.errors = {}
if(this.$empty(this.record._source)) this.errors.source = 'Chưa chọn tài khoản nguồn'
if(this.$empty(this.record._target)) this.errors.target = 'Chưa chọn tài khoản đích'
if(Object.keys(this.errors).length===0) {
if(this.record._source.id===this.record._target.id) this.errors.target = 'Tài khoản nguồn phải khác tài khoản đích'
}
if(this.$empty(this.record.amount)) this.errors.amount = 'Chưa nhập số tiền'
else if(this.$formatNumber(this.record.amount)<=0) this.errors.amount = 'Số tiền phải > 0'
else if(this.record._source.balance<this.$formatNumber(this.record.amount)) this.errors.source = 'Tài khoản nguồn không đủ số để điều chuyển'
if(this.$empty(this.record.content)) this.errors.content = 'Chưa nhập nội dung'
return Object.keys(this.errors).length>0
},
confirm() {
if(this.checkError()) return
this.showmodal = {component: `dialog/Confirm`,vbind: {content: this.$lang('confirm-action'), duration: 10},
title: this.$lang('confirm'), width: '500px', height: '100px'}
},
async update() {
let content = `${this.record.content} (${this.record._source.code} -> ${this.record._target.code})`
let obj1 = {code: this.record._source.code, amount: this.record.amount, content: content, type: 'DR', category: 2, user: this.$store.login.id}
let rs1 = await this.$insertapi('accountentry', obj1, undefined, false)
if(rs1==='error') return
let obj2 = {code: this.record._target.code, amount: this.record.amount, content: content, type: 'CR', category: 2, user: this.$store.login.id}
let rs2 = await this.$insertapi('accountentry', obj2, undefined, false)
if(rs2==='error') return
let data = await this.$getdata('internalaccount', {code__in: [this.record._source.code, this.record._target.code]})
this.$updatepage(this.pagename, data)
this.$dialog(`Điều chuyển vốn <b>${this.$numtoString(this.record.amount)}</b> từ <b>${this.record._source.code}</b> tới <b>${this.record._target.code}</b> thành công.`, 'Thành công', 'Success', 10)
this.$emit('close')
}
}
}
</script>

View File

@@ -0,0 +1,290 @@
<template>
<div class="content-transaction-invoice">
<div class="container is-fluid px-4">
<div class="columns">
<div class="column">
<label class="label">Link<b class="ml-1 has-text-danger">*</b></label>
</div>
<div class="column is-2">
<label class="label"> tra cứu<b class="ml-1 has-text-danger">*</b></label>
</div>
<div class="column is-2">
<label class="label">Số tiền<b class="ml-1 has-text-danger">*</b></label>
</div>
<div class="column is-2">
<label class="label">Loại tiền<b class="ml-1 has-text-danger">*</b></label>
</div>
<div class="column is-1"></div>
</div>
<div class="columns" v-for="(invoice, index) in invoices">
<div class="column">
<input
class="input has-text-centered has-text-weight-bold has-text-left"
type="text"
placeholder="Nhập link tra cứu"
v-model="invoice.link"
@blur="
validateField({
value: invoice.link,
type: 'link',
index,
field: 'errorLink',
})
"
/>
<p v-if="invoice.errorLink" class="help is-danger">Link phải bắt đầu bằng https</p>
</div>
<div class="column is-2">
<input
class="input has-text-centered has-text-weight-bold has-text-left"
type="text"
placeholder="Nhập mã tra cứu"
v-model="invoice.ref_code"
@blur="
validateField({
value: invoice.ref_code,
type: 'text',
index,
field: 'errorCode',
})
"
/>
<p v-if="invoice.errorCode" class="help is-danger"> tra cứu không được bỏ trống</p>
</div>
<div class="column is-2">
<input
class="input has-text-centered has-text-weight-bold has-text-right"
type="number"
placeholder="Số tiền"
v-model="invoice.amount"
@blur="
validateField({
value: invoice.amount,
type: 'text',
index,
field: 'errorAmount',
})
"
/>
<p v-if="invoice.errorAmount" class="help is-danger">Số tiền không được bỏ trống</p>
</div>
<div class="column is-2">
<select
v-model="invoice.note"
style="width: 100%; height: var(--bulma-control-height)"
@blur="
validateField({
value: invoice.note,
type: 'text',
index,
field: 'errorType',
})
"
>
<option value="principal">Tiền gốc</option>
<option value="interest">Tiền lãi</option>
</select>
<p v-if="invoice.errorType" class="help is-danger">Loại tiền không được bỏ trống</p>
</div>
<div class="column is-narrow is-1">
<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="handlerRemove(index)">
<span class="icon">
<SvgIcon v-bind="{ name: 'bin1.svg', type: 'white', size: 20 }"></SvgIcon>
</span>
</button>
<button class="button is-dark" @click="add()">
<span class="icon">
<SvgIcon v-bind="{ name: 'add4.svg', type: 'white', size: 20 }"></SvgIcon>
</span>
</button>
<a class="button is-dark" :href="invoice.link" target="_blank">
<span class="icon">
<SvgIcon v-bind="{ name: 'view.svg', type: 'white', size: 20 }"></SvgIcon>
</span>
</a>
</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="handlerUpdate">{{ isVietnamese ? "Lưu lại" : "Save" }}</button>
</div>
</div>
</div>
</template>
<script setup>
const { $snackbar, $getdata, $insertapi, $store, $updateapi, $deleteapi, $formatNumber } = useNuxtApp();
const isVietnamese = computed(() => $store.lang.toLowerCase() === "vi");
const invoices = ref([{}]);
const delInvoices = ref([]);
const emit = defineEmits(["close"]);
const props = defineProps({
row: Object,
});
const resInvoice = await $getdata("invoice", { payment: props.row.id }, undefined, false);
const validateField = ({ value, type = "text", index, field }) => {
if (index < 0 || index >= invoices.value.length) return false;
const val = value?.toString().trim();
let isInvalid = false;
// 1. Không được bỏ trống (áp dụng cho tất cả)
if (!val) {
isInvalid = true;
}
// 2. Validate theo type
if (!isInvalid && type === "link") {
isInvalid = !/^https:\/\//.test(val);
}
// set lỗi
invoices.value[index][field] = isInvalid;
return !isInvalid;
};
if (resInvoice.length) {
const error = {
errorLink: false,
errorCode: false,
errorAmount: false,
errorType: false,
};
const formatData = resInvoice.map((invoice) => ({ ...invoice, amount: $formatNumber(invoice.amount), ...error }));
invoices.value = formatData;
}
const add = () => invoices.value.push({});
const validateAll = () => {
const errors = [];
invoices.value.forEach((inv, index) => {
const checks = [
{
value: inv.link,
type: "link",
field: "errorLink",
label: "Link",
},
{
value: inv.ref_code,
type: "number",
field: "errorCode",
label: "Mã tham chiếu",
},
{
value: inv.amount,
type: "number",
field: "errorAmount",
label: "Số tiền",
},
{
value: inv.note,
type: "number",
field: "errorType",
label: "Số tiền",
},
];
checks.forEach(({ value, type, field, label }) => {
const isValid = validateField({
value,
type,
index,
field,
});
if (!isValid) {
errors.push({
index,
field,
label,
});
}
});
});
return {
valid: errors.length === 0,
errors,
};
};
const handlerUpdate = async () => {
try {
// 1. Insert / Update
if (!validateAll()?.valid) {
$snackbar("Dữ liệu chưa hợp lệ");
return;
}
for (const invoice of invoices.value) {
let res;
if (invoice.id) {
res = await $updateapi("invoice", invoice, undefined, false);
} else {
const dataSend = {
...invoice,
payment: props.row.id,
};
res = await $insertapi("invoice", dataSend, undefined, false);
}
if (!res || res === "error") {
throw new Error("Save invoice failed");
}
}
// 2. Delete
for (const id of delInvoices.value) {
const res = await $deleteapi("invoice", id);
if (!res || res === "error") {
throw new Error("Delete invoice failed");
}
}
$snackbar("Lưu hóa đơn thành công");
emit("close");
} catch (err) {
console.error(err);
$snackbar("Có lỗi khi lưu hóa đơn");
}
};
const handlerRemove = (index) => {
if (index < 0 || index >= invoices.value.length) return;
const [removed] = invoices.value.splice(index, 1);
if (removed?.id && !delInvoices.value.includes(removed.id)) {
delInvoices.value.push(removed.id);
}
};
</script>
<style>
.content-transaction-invoice input,
select {
border-radius: 5px;
border-color: gray;
}
.content-transaction-invoice input[type="number"]::-webkit-inner-spin-button,
.content-transaction-invoice input[type="number"]::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
.content-transaction-invoice input[type="number"] {
-moz-appearance: textfield;
}
</style>

View File

@@ -0,0 +1,21 @@
<template>
<ImageGallery
v-bind="{ row, pagename, show, api: 'applicationfile' }"
@remove="emit('remove')"
@update="update"
></ImageGallery>
</template>
<script setup>
import ImageGallery from "../media/ImageGallery.vue";
import { defineProps, defineEmits } from "vue";
const props = defineProps({
row: Object,
pagename: String,
api: String,
});
const emit = defineEmits(["remove"]);
</script>

View File

@@ -0,0 +1,964 @@
<template>
<div class="fixed-grid has-12-cols pb-2">
<div id="customer-selection" class="grid px-3 py-3 m-0 has-background-white">
<!-- Product Selection -->
<div v-if="!productId" class="cell is-col-span-5">
<p class="is-size-6 has-text-weight-bold mb-2">Chọn sản phẩm</p>
<SearchBox v-bind="{
api: 'product',
field: 'label',
searchfield: ['code', 'trade_code', 'type__name', 'land_lot_size', 'zone_type__name'],
column: ['code', 'trade_code', 'type__name', 'land_lot_size', 'zone_type__name'],
first: true,
filter: { status: 2, cart__dealer: store.dealer?.id },
viewaddon: productViewAddon,
}" @option="handleProductSelection" />
</div>
<!-- Customer Selection -->
<div class="cell" :class="productId ? 'is-col-span-10' : 'is-col-span-5'">
<p class="is-size-6 has-text-weight-bold mb-2">Chọn khách hàng</p>
<SearchBox v-bind="{
api: 'customer',
field: 'label',
searchfield: ['code', 'fullname', 'phone', 'legal_code'],
column: ['code', 'fullname', 'phone', 'legal_code'],
first: true,
disabled: !productData,
viewaddon: customerViewAddon,
addon: $getEditRights('edit', { code: 'customer', category: 'topmenu' }) ? customerViewAdd : undefined,
}" @option="handleCustomerSelection" />
</div>
<!-- Action Buttons -->
<div class="cell is-col-span-2 is-flex is-align-items-flex-end">
<button v-if="contractData" class="button is-info has-text-white is-fullwidth" @click="openContractModal">
<span>Xem hợp đồng</span>
</button>
<button v-else-if="$getEditRights()" class="button is-success has-text-white is-fullwidth" :disabled="!canCreateTransaction"
:class="{ 'is-loading': isCreatingTransaction }" @click="createTransaction" style="height: 2.5em;">
Tạo giao dịch
</button>
</div>
</div>
<!-- Policy Selection -->
<div v-if="productData && policies.length > 0" class="px-3 py-3 m-0 has-background-white mb-3">
<p class="is-size-6 has-text-weight-bold mb-3">Chọn chính sách thanh toán</p>
<div class="tabs is-toggle">
<ul>
<li v-for="policy in policies" :key="policy.id" :class="{ 'is-active': selectedPolicy?.id === policy.id }">
<a @click="selectPolicy(policy)">
<span>{{ policy.name }}</span>
</a>
</li>
</ul>
</div>
</div>
<!-- Contract Date -->
<div v-if="productData && selectedPolicy" class="px-3 mb-3">
<div class="columns">
<div class="column is-3">
<div class="field">
<label class="label">Ngày ký</label>
<Datepicker :record="dateRecord" attr="contractDate" @date="updateContractDate" position="is-bottom-left" />
</div>
</div>
</div>
</div>
<!-- Gift Selection -->
<div v-if="productData && selectedPolicy" class="px-3 mb-3 has-background-white p-4">
<p class="is-size-6 has-text-weight-bold mb-3">Chọn quà tặng</p>
<div v-if="availableGifts.length > 0" class="buttons">
<button
v-for="gift in availableGifts"
:key="gift.id"
class="button"
:class="isGiftSelected(gift.id) ? 'is-success' : 'is-primary is-outlined'"
@click="toggleGift(gift)">
<span class="icon" v-if="isGiftSelected(gift.id)">
<SvgIcon v-bind="{ name: 'check.svg', type: 'white', size: 16 }"></SvgIcon>
</span>
<span>{{ gift.name }}</span>
</button>
</div>
<div v-else class="notification is-light">
Không có quà tặng khả dụng
</div>
</div>
<!-- Discount Selection -->
<div v-if="productData && selectedPolicy && selectedPolicy.contract_allocation_percentage == 100" class="px-3 mb-5">
<p class="is-size-6 has-text-weight-bold mb-2">Chọn chiết khấu</p>
<div class="columns is-multiline">
<div class="column is-12" v-for="(row, index) in discountRows" :key="row.key"
draggable="true"
@dragstart="dragStart"
@dragover="dragOver"
@drop="drop"
@dragend="dragEnd"
:data-index="index"
style="cursor: move; border-bottom: 1px solid #204853;">
<div class="columns is-mobile is-vcentered m-0 is-variable is-1">
<div class="column is-narrow" style="display: flex; align-items: center; justify-content: center;">
<SvgIcon v-bind="{ name: 'dot.svg', type: 'primary', size: 16 }"></SvgIcon>
</div>
<div class="column" :class="row.selectedData?.type === 1 ? 'is-4' : 'is-6'">
<SearchBox v-bind="{
api: 'discounttype',
field: 'label',
searchfield: ['code', 'name', 'value'],
column: ['code', 'name', 'value'],
first: true,
clearable: true,
placeholder: 'Chọn loại chiết khấu...'
}" @option="(val) => handleRowSelect(index, val)" />
</div>
<!-- THÊM DROPDOWN CHỌ BASE PRICE TYPE -->
<div v-if="row.selectedData?.type === 1" class="column is-3">
<div class="select is-fullwidth">
<select v-model="row.basePriceType" @change="recalculateDiscount(index)">
<option value="contract">Giá trị hợp đồng</option>
<option value="with_vat">Giá đã VAT</option>
<option value="without_vat">Giá chưa VAT</option>
</select>
</div>
</div>
<div class="column is-4" >
<div class="control has-icons-right">
<input
class="input has-text-centered has-text-weight-bold"
type="number"
v-model.number="row.customValue"
@input="validateRowValue(index)"
placeholder="0"
:disabled="!row.selectedData">
<span v-if="row.selectedData" class="icon is-right has-text-grey is-size-7" style="height: 100%">
{{ row.selectedData.type === 1 ? '%' : 'đ' }}
</span>
</div>
</div>
<div class="column">
<button class="button is-warning is-fullwidth" @click="removeDiscountRow(index)">
<span class="icon">
<SvgIcon v-bind="{ name: 'bin1.svg', type: 'white', size: 16 }"></SvgIcon>
</span>
</button>
</div>
</div>
</div>
<div class="column is-12">
<button class="button is-text is-fullwidth" @click="addNewDiscountRow">
<SvgIcon v-bind="{ name: 'add4.svg', type: 'primary', size: 18 }" />
<span class="ml-2">Thêm chiết khấu</span>
</button>
</div>
</div>
</div>
<!-- Early Payment Section -->
<div
v-if="productData && selectedPolicy && selectedPolicy.contract_allocation_percentage == 100 && selectedPolicy.method === 1"
class="px-3 has-background-white p-4">
<div class="level is-mobile mb-3">
<div class="level-left">
<label class="checkbox">
<input type="checkbox" v-model="enableEarlyPayment" @change="handleEarlyPaymentToggle">
<span class="is-size-6 has-text-weight-semibold has-text-primary ml-2">
Thanh toán sớm (2 - {{ maxEarlyCycles }}) đợt
</span>
</label>
</div>
</div>
<transition name="fade">
<div v-if="enableEarlyPayment && maxEarlyCycles >= 2" class="field">
<label class="label">Số đợt thanh toán sớm</label>
<div class="control" style="max-width: 200px;">
<input class="input" type="number" v-model.number="earlyPaymentCycles" :min="2" :max="maxEarlyCycles"
@input="validateEarlyCycles">
</div>
</div>
</transition>
</div>
<!-- Payment Schedule Presentation Component -->
<PaymentSchedulePresentation v-if="productData && policies.length > 0" :productData="productData"
:selectedPolicy="selectedPolicy" :selectedCustomer="selectedCustomer" :calculatorData="calculatorOutputData"
:isLoading="isCreatingTransaction" @print="printContent" />
</div>
<!-- Modals -->
<Modal v-if="showPhaseModal" component="transaction/TransactionPhaseForm" title="Chọn loại giao dịch" width="40%"
height="auto" @close="showPhaseModal = false" @phaseSelected="handlePhaseSelection"
:vbind="{ filterPhases: phaseFilterOptions }" />
<Modal v-if="showConfirmModal" @close="showConfirmModal = false" @confirm="executeTransactionCreation"
v-bind="confirmModalConfig" />
<Modal v-if="showContractModal" @close="showContractModal = false" @dataevent="handleContractUpdated"
v-bind="contractModalConfig" />
</template>
<script>
import { useStore } from "@/stores/index";
import { computed, watch, ref } from 'vue';
import dayjs from 'dayjs';
import SearchBox from '~/components/SearchBox.vue';
import Modal from '~/components/Modal.vue';
import TransactionConfirmModal from '~/components/transaction/TransactionConfirmModal.vue';
import PaymentSchedulePresentation from './PaymentSchedulePresentation.vue';
import Datepicker from '~/components/datepicker/Datepicker.vue';
import SvgIcon from '~/components/SvgIcon.vue';
import { useAdvancedWorkflow } from '@/composables/useAdvancedWorkflow';
import { usePaymentCalculator } from '@/composables/usePaymentCalculator';
export default {
components: {
SearchBox,
Modal,
TransactionConfirmModal,
PaymentSchedulePresentation,
Datepicker,
SvgIcon,
},
props: {
productId: {
type: [String, Number],
default: null
}
},
setup() {
const store = useStore();
const { $snackbar, $getdata, $dialog, $exportpdf } = useNuxtApp();
const { isLoading, createWorkflowTransaction } = useAdvancedWorkflow();
const calculator = usePaymentCalculator();
return {
store,
$snackbar,
$getdata,
$dialog,
$exportpdf,
isCreatingTransaction: isLoading,
createFullTransaction: createWorkflowTransaction,
calculator
};
},
data() {
const initialContractDate = dayjs().format('YYYY-MM-DD');
return {
productData: null,
selectedCustomer: null,
policies: [],
selectedPolicy: null,
allPaymentPlans: [],
paymentPlans: [],
// Gift Selection
availableGifts: [],
selectedGifts: [],
discountRows: [{
key: Date.now(),
selectedData: null,
customValue: 0,
basePriceType: 'contract',
calculatedAmount: 0
}],
draggedIndex: null,
// Early Payment
enableEarlyPayment: false,
earlyPaymentCycles: 2,
// Contract Date
contractDate: initialContractDate,
dateRecord: { contractDate: initialContractDate },
productViewAddon: {
component: "product/ProductView",
width: "70%",
height: "500px",
title: "Thông tin sản phẩm",
},
customerViewAddon: {
component: "customer/CustomerView",
width: "70%",
height: "500px",
title: "Thông tin khách hàng",
},
customerViewAdd: {
component: "customer/CustomerInfo2",
width: "60%",
height: "auto",
title: this.store.lang === "en" ? "Edit Customer" : "Chỉnh sửa khách hàng",
},
showPhaseModal: false,
selectedPhaseInfo: null,
showConfirmModal: false,
contractData: null,
showContractModal: false,
};
},
computed: {
canCreateTransaction() {
return this.productData && this.selectedCustomer && this.selectedPolicy;
},
maxEarlyCycles() {
return this.paymentPlans?.length || 0;
},
calculatorOutputData() {
return {
originPrice: this.calculator.originalPrice.value,
totalDiscount: this.calculator.totalDiscount.value,
salePrice: this.calculator.finalTotal.value,
allocatedPrice: this.calculator.finalTotal.value,
originalPaymentSchedule: this.calculator.originalPaymentSchedule.value,
finalPaymentSchedule: this.calculator.finalPaymentSchedule.value,
earlyDiscountDetails: this.calculator.earlyDiscountDetails.value,
totalRemaining: this.calculator.totalRemaining.value,
detailedDiscounts: this.calculator.detailedDiscounts.value,
baseDate: this.calculator.startDate.value
};
},
phaseFilterOptions() {
if (!this.selectedPolicy) {
return ['reserved', 'deposit', 'fulfillwish'];
}
if (this.store.dealer) {
return ['reserved', 'deposit'];
}
if (this.selectedPolicy.code === 'CS-TT-THNV') {
return ['fulfillwish', 'reserved'];
} else {
return ['deposit', 'reserved'];
}
},
confirmModalConfig() {
if (!this.productData || !this.selectedPhaseInfo || !this.selectedPolicy || !this.selectedCustomer) return {};
return {
component: "transaction/TransactionConfirmModal",
title: "Xác nhận Giao dịch",
width: "60%",
height: "auto",
vbind: {
productData: this.productData,
phaseInfo: this.selectedPhaseInfo,
selectedPolicy: this.selectedPolicy,
originPrice: this.calculator.originalPrice.value,
discountValueDisplay: this.calculator.totalDiscount.value,
selectedCustomer: this.selectedCustomer,
initialContractDate: this.contractDate
}
}
},
contractModalConfig() {
if (!this.contractData) return {};
return {
component: "application/Contract",
title: "Hợp đồng giao dịch",
width: "95%",
height: "95vh",
vbind: {
row: {
id: this.contractData.id
}
},
event: "contractUpdated",
eventname: "dataevent",
}
},
},
watch: {
// Watch discountRows để cập nhật vào calculator
discountRows: {
handler(newRows) {
const validDiscounts = newRows
.filter(r => r.selectedData)
.map(r => {
if (r.selectedData.type === 1) {
// Phần trăm - tính toán dựa trên base price type
let basePrice = 0;
switch (r.basePriceType) {
case 'with_vat':
basePrice = this.productData?.origin_price || 0;
break;
case 'without_vat':
basePrice = this.productData?.price_excluding_vat ||
(this.productData?.origin_price || 0) / 1.1;
break;
case 'contract':
default:
return {
id: r.selectedData.id,
name: r.selectedData.name,
code: r.selectedData.code,
type: r.selectedData.type,
value: r.customValue || 0
};
}
const calculatedAmount = (basePrice * (r.customValue || 0)) / 100;
r.calculatedAmount = calculatedAmount;
return {
id: r.selectedData.id,
name: `${r.selectedData.name} (${r.basePriceType === 'with_vat' ? 'Giá đã VAT' : 'Giá chưa VAT'})`,
code: r.selectedData.code,
type: 2, // Chuyển sang type 2 (tiền mặt) sau khi tính toán
value: Math.round(calculatedAmount)
};
} else {
// Tiền mặt
r.calculatedAmount = 0;
return {
id: r.selectedData.id,
name: r.selectedData.name,
code: r.selectedData.code,
type: r.selectedData.type,
value: r.customValue || 0
};
}
});
this.calculator.discounts.value = validDiscounts;
},
deep: true
},
earlyPaymentCycles(newVal) {
if (this.enableEarlyPayment && newVal > 0) {
this.calculator.earlyPaymentCycles.value = newVal;
}
},
enableEarlyPayment(newVal) {
if (!newVal) {
this.calculator.earlyPaymentCycles.value = 0;
} else {
this.calculator.earlyPaymentCycles.value = this.earlyPaymentCycles;
}
},
contractDate(newVal) {
if (newVal) {
this.calculator.startDate.value = new Date(newVal);
}
}
},
async created() {
await this.loadAllData();
if (this.productId) {
await this.loadProductById(this.productId);
}
},
methods: {
async loadProductById(id) {
const product = await this.$getdata('product', { id: id }, undefined, true);
if (product) {
await this.handleProductSelection(product);
} else {
this.$snackbar(`Không tìm thấy sản phẩm với ID: ${id}`, { type: 'is-danger' });
}
},
updateContractDate(newDate) {
this.contractDate = newDate;
this.dateRecord.contractDate = newDate;
this.calculator.startDate.value = new Date(newDate);
},
handleEarlyPaymentToggle() {
if (this.enableEarlyPayment) {
this.earlyPaymentCycles = Math.min(2, this.maxEarlyCycles);
this.calculator.earlyPaymentCycles.value = this.earlyPaymentCycles;
} else {
this.calculator.earlyPaymentCycles.value = 0;
}
},
validateEarlyCycles() {
if (this.earlyPaymentCycles < 2) {
this.earlyPaymentCycles = 2;
}
if (this.earlyPaymentCycles > this.maxEarlyCycles) {
this.earlyPaymentCycles = this.maxEarlyCycles;
}
this.calculator.earlyPaymentCycles.value = this.earlyPaymentCycles;
},
// Gift Selection Methods
isGiftSelected(giftId) {
return this.selectedGifts.some(g => g.id === giftId);
},
toggleGift(gift) {
const index = this.selectedGifts.findIndex(g => g.id === gift.id);
if (index > -1) {
// Đã chọn -> bỏ chọn
this.selectedGifts.splice(index, 1);
} else {
// Chưa chọn -> thêm vào
this.selectedGifts.push({ id: gift.id });
}
},
async loadAvailableGifts() {
try {
const gifts = await this.$getdata('gift', undefined, undefined, false);
if (gifts && Array.isArray(gifts)) {
this.availableGifts = gifts;
}
} catch (error) {
console.error('Error loading gifts:', error);
this.availableGifts = [];
}
},
addNewDiscountRow() {
this.discountRows.push({
key: Date.now(),
selectedData: null,
customValue: 0,
basePriceType: 'contract',
calculatedAmount: 0
});
},
removeDiscountRow(index) {
this.discountRows.splice(index, 1);
if (!this.discountRows.length) this.addNewDiscountRow();
},
handleRowSelect(index, data) {
const row = this.discountRows[index];
if (!data) {
row.selectedData = null;
row.customValue = 0;
row.basePriceType = 'contract';
row.calculatedAmount = 0;
} else {
row.selectedData = data;
row.customValue = data.value;
if (data.type === 1) {
row.basePriceType = 'contract';
this.recalculateDiscount(index);
} else {
row.calculatedAmount = 0;
}
}
},
recalculateDiscount(index) {
const row = this.discountRows[index];
if (!row.selectedData || row.selectedData.type !== 1) return;
let basePrice = 0;
switch (row.basePriceType) {
case 'with_vat':
basePrice = this.productData?.origin_price || 0;
break;
case 'without_vat':
basePrice = this.productData?.price_excluding_vat ||
(this.productData?.origin_price || 0) / 1.1;
break;
case 'contract':
default:
row.calculatedAmount = 0;
return;
}
row.calculatedAmount = (basePrice * (row.customValue || 0)) / 100;
},
validateRowValue(index) {
const row = this.discountRows[index];
if (!row.selectedData) return;
if (row.selectedData.type === 1) {
if (row.customValue > 100) row.customValue = 100;
if (row.customValue < 0) row.customValue = 0;
this.recalculateDiscount(index);
} else {
if (row.customValue < 0) row.customValue = 0;
}
},
dragStart(e) {
this.draggedIndex = parseInt(e.currentTarget.getAttribute('data-index'));
e.currentTarget.style.opacity = '0.5';
},
dragOver(e) {
e.preventDefault();
const target = e.currentTarget;
if (target.getAttribute('data-index') !== null) {
target.style.borderTop = '2px solid #204853';
}
},
drop(e) {
e.preventDefault();
const dropIndex = parseInt(e.currentTarget.getAttribute('data-index'));
if (this.draggedIndex !== null && this.draggedIndex !== dropIndex) {
const draggedRow = this.discountRows[this.draggedIndex];
this.discountRows.splice(this.draggedIndex, 1);
this.discountRows.splice(dropIndex, 0, draggedRow);
}
document.querySelectorAll('[data-index]').forEach(row => {
row.style.borderTop = '';
row.style.opacity = '1';
});
},
dragEnd(e) {
document.querySelectorAll('[data-index]').forEach(row => {
row.style.borderTop = '';
row.style.opacity = '1';
});
this.draggedIndex = null;
},
handleCustomerSelection(customer) {
this.selectedCustomer = customer;
},
async handleProductSelection(product) {
if (!product) {
this.productData = null;
this.selectedPolicy = null;
this.paymentPlans = [];
this.discountRows = [{
key: Date.now(),
selectedData: null,
customValue: 0,
basePriceType: 'contract',
calculatedAmount: 0
}];
this.selectedCustomer = null;
this.contractData = null;
this.enableEarlyPayment = false;
this.earlyPaymentCycles = 2;
this.selectedGifts = [];
this.calculator.originPrice.value = 0;
this.calculator.discounts.value = [];
this.calculator.paymentPlan.value = [];
this.calculator.paidAmount.value = 0;
this.calculator.earlyPaymentCycles.value = 0;
return;
}
if (!product.trade_code && !product.code) {
this.$snackbar('Không tìm thấy mã sản phẩm hợp lệ', { type: 'is-danger' });
return;
}
this.productData = product;
this.selectedPolicy = null;
this.paymentPlans = [];
this.discountRows = [{
key: Date.now(),
selectedData: null,
customValue: 0,
basePriceType: 'contract',
calculatedAmount: 0
}];
this.contractData = null;
this.enableEarlyPayment = false;
this.earlyPaymentCycles = 2;
this.selectedGifts = [];
this.calculator.originPrice.value = product.origin_price || 0;
this.calculator.discounts.value = [];
this.calculator.startDate.value = new Date(this.contractDate);
this.calculator.earlyPaymentCycles.value = 0;
if (this.policies.length > 0) {
this.selectPolicy(this.policies[0]);
} else {
this.$snackbar('Không có chính sách thanh toán cho sản phẩm này', { type: 'is-warning' });
}
},
async loadAllData() {
try {
this.policies = await this.$getdata("salepolicy", { enable: "True" }, undefined, false);
this.allPaymentPlans = await this.$getdata("paymentplan", { policy__enable: "True" }, undefined, false);
if (this.allPaymentPlans) {
this.allPaymentPlans.sort((a, b) => a.cycle - b.cycle);
}
// Load gifts
await this.loadAvailableGifts();
} catch (error) {
console.error('Error loading data:', error);
}
},
selectPolicy(policy) {
this.selectedPolicy = policy;
this.loadPlansForPolicy(policy.id);
this.calculator.contractAllocationPercentage.value = policy.contract_allocation_percentage || 100;
this.enableEarlyPayment = false;
this.earlyPaymentCycles = 2;
this.calculator.earlyPaymentCycles.value = 0;
if (policy.contract_allocation_percentage < 100) {
this.discountRows = [{
key: Date.now(),
selectedData: null,
customValue: 0,
basePriceType: 'contract',
calculatedAmount: 0
}];
}
},
loadPlansForPolicy(id) {
const policy = this.policies.find(pol => pol.id === id);
if (!policy) return;
this.selectedPolicy = policy;
this.paymentPlans = this.allPaymentPlans.filter(plan => plan.policy === id);
if (this.paymentPlans.length > 0) {
this.calculator.paymentPlan.value = this.paymentPlans.map(p => ({
cycle: p.cycle,
value: p.value,
type: p.type,
days: p.days,
payment_note: p.payment_note,
due_note: p.due_note
}));
}
},
createTransaction() {
const invalidRow = this.discountRows.find(r => r.selectedData && r.selectedData.type !== 1 && r.customValue < 1000);
if (invalidRow) {
this.$snackbar('Giá trị tiền mặt phải >= 1.000', { type: 'is-warning' });
return;
}
this.showPhaseModal = true;
},
handlePhaseSelection(phaseInfo) {
this.showPhaseModal = false;
this.selectedPhaseInfo = phaseInfo;
this.showConfirmModal = true;
},
async executeTransactionCreation({ currentDate, dueDate, paymentAmount, depositReceived, people }) {
this.showConfirmModal = false;
this.calculator.startDate.value = new Date(currentDate);
this.calculator.paidAmount.value = 0;
const finalSchedule = this.calculator.finalPaymentSchedule.value;
const paymentPlansForBackend = finalSchedule.map((plan, index) => ({
amount: plan.amount,
due_days: plan.days,
cycle: plan.cycle,
note: plan.payment_note,
is_early_merged: plan.is_merged || false,
merged_cycles: plan.is_merged ? (plan.original_cycles || []) : [],
raw_amount: plan.amount,
paid_amount: plan.paid_amount,
remain_amount: plan.remain_amount,
from_date: dayjs(plan.from_date).format('YYYY-MM-DD'),
to_date: dayjs(plan.to_date).format('YYYY-MM-DD'),
due_note: plan.due_note || ''
}));
if (paymentPlansForBackend.length > 0) {
paymentPlansForBackend[0].remain_amount = Math.max(0, paymentPlansForBackend[0].remain_amount - paymentAmount);
paymentPlansForBackend[0].amount = Math.max(0, paymentPlansForBackend[0].amount - paymentAmount);
}
const totalEarlyDiscount = this.enableEarlyPayment
? this.calculator.totalEarlyDiscount.value
: 0;
const params = {
product: this.productData,
customer: this.selectedCustomer,
policy: this.selectedPolicy,
phaseInfo: this.selectedPhaseInfo,
priceAfterDiscount: this.calculator.finalTotal.value,
discountValue: this.calculator.totalDiscount.value,
detailedDiscounts: this.calculator.detailedDiscounts.value,
paymentPlans: paymentPlansForBackend,
currentDate,
reservationDueDate: dueDate,
reservationAmount: paymentAmount,
depositReceived: depositReceived,
people: people,
earlyDiscountAmount: totalEarlyDiscount,
gifts: this.selectedGifts // Thêm danh sách quà tặng
};
const result = await this.createFullTransaction(params);
if (result && result.transaction) {
this.contractData = result.transaction;
this.showContractModal = true;
} else {
this.$snackbar('Tạo giao dịch thất bại. Vui lòng thử lại.', { type: 'is-danger' });
}
this.selectedPhaseInfo = null;
},
openContractModal() {
if (!this.contractData) {
this.$snackbar('Chưa có dữ liệu hợp đồng để xem.', { type: 'is-warning' });
return;
}
this.showContractModal = true;
},
handleContractUpdated(eventData) {
if (eventData?.data) {
this.contractData = { ...this.contractData, ...eventData.data };
this.$snackbar("Hợp đồng đã được cập nhật");
}
},
printContent() {
if (!this.productData || !this.selectedPolicy) {
this.$snackbar('Vui lòng tìm sản phẩm và chọn chính sách', { type: 'is-warning' });
return;
}
const docId = 'print-area';
const fileName = `${this.selectedPolicy?.name || 'Payment Schedule'} - ${this.productData?.code}`;
const printElement = document.getElementById(docId);
if (!printElement) return;
const scheduleContainers = printElement.querySelectorAll('.schedule-container');
const stickyHeaders = printElement.querySelectorAll('.table-container.schedule-container thead th');
const ignoreButtons = printElement.querySelectorAll('#ignore-print');
scheduleContainers.forEach(container => {
container.style.maxHeight = 'none';
container.style.overflow = 'visible';
});
stickyHeaders.forEach(header => {
header.style.position = 'static';
});
ignoreButtons.forEach(button => {
button.style.display = 'none';
});
this.$exportpdf(docId, fileName, 'a3');
setTimeout(() => {
scheduleContainers.forEach(container => {
container.style.maxHeight = '';
container.style.overflow = '';
});
stickyHeaders.forEach(header => {
header.style.position = '';
});
ignoreButtons.forEach(button => {
button.style.display = '';
});
}, 1200);
this.$snackbar('Đang xuất PDF...', { type: 'is-info' });
}
}
};
</script>
<style scoped>
#customer-selection {
position: sticky;
z-index: 5;
top: 52px;
}
.modal-card-body #customer-selection {
top: -16px;
}
.button.is-dashed-border {
border: 1px dashed #b5b5b5;
transition: all 0.2s;
}
.button.is-dashed-border:hover {
border-color: #204853;
color: #204853 !important;
background-color: #f5f5f5;
}
.schedule-container {
max-height: 400px;
overflow-y: auto;
}
.table-container.schedule-container thead th {
position: sticky;
top: 0;
background: white;
z-index: 2;
border-bottom: 1px solid #dbdbdb !important;
}
.table-container {
max-height: 300px;
overflow-y: auto;
}
li.is-active a,
li a:hover {
color: white !important;
background-color: #204853 !important;
transition: all 0.3s ease;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
@media print {
.schedule-container {
max-height: none;
overflow: visible;
}
.table-container.schedule-container thead th {
position: static;
}
#ignore-print {
display: none !important;
}
}
</style>

View File

@@ -0,0 +1,148 @@
<template>
<div :id="docid">
<div :id="docid1">
<Caption v-bind="{ title: isVietnamese? 'Thanh toán' : 'Payment', type: 'has-text-warning' }"></Caption>
<div class="columns is-multiline mx-0">
<div class="column is-4 pb-1 px-0">
<div class="field">
<label class="label">{{ dataLang && findFieldName("loan_code")[lang] }}</label>
<div class="control">
<span class="hyperlink" @click="$copyToClipboard(record.code)">{{ record?.code || "/" }}</span>
</div>
</div>
</div>
<div class="column is-4 pb-1 px-0">
<div class="field">
<label class="label">{{ dataLang && findFieldName("name")[lang] }}</label>
<div class="control">
<span>{{ record?.fullname || "/" }}</span>
</div>
</div>
</div>
<div class="column is-4 pb-1 px-0">
<div class="field">
<label class="label">{{ dataLang && findFieldName("phone_number")[lang] }}</label>
<div class="control">
<span>{{ record?.phone || "/" }}</span>
</div>
</div>
</div>
<div class="column is-4 pb-1 px-0">
<div class="field">
<label class="label">{{ dataLang && findFieldName("modalcollaboratorcode")[lang] }}</label>
<div class="control">
<span>{{ record?.collaborator__code || "/" }}</span>
</div>
</div>
</div>
<div class="column is-4 pb-1 px-0">
<div class="field">
<label class="label">{{ isVietnamese ? "Họ tên CTV" : "CTV name" }}</label>
<div class="control">
<span>{{ record?.collaborator__fullname || "/" }}</span>
</div>
</div>
</div>
<div class="column is-4 pb-1 px-0">
<div class="field">
<label class="label">{{ dataLang && findFieldName("commissionamount")[lang] }}</label>
<div class="control">
<span>{{ record?.commission ? $numtoString(record.commission) : "/" }}</span>
</div>
</div>
</div>
<div class="column is-5 pb-1 px-0">
<div class="field">
<label class="label">{{ isVietnamese ? " Trạng thái" : "Status" }}</label>
<div class="control">
<SearchBox
true
v-bind="{
api: 'paymentstatus',
field: isVietnamese ? 'name' : 'en',
column: ['code'],
first: true,
optionid: record.payment_status ? record.payment_status : 1,
}"
@option="selected('payment_status', $event)"
position="is-top-left"
></SearchBox>
</div>
</div>
</div>
</div>
</div>
<!-- <div class="mt-2 border-bottom"></div> -->
<div class="buttons mt-5" id="ignore">
<button class="button is-primary has-text-white mt-2" @click="handleUpdate()">
{{ dataLang && findFieldName("update")[lang] }}
</button>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, computed } from "vue";
import { useStore } from "@/stores/index";
import { useNuxtApp } from "#app";
const nuxtApp = useNuxtApp();
const {
$updatepage,
$getdata,
$updateapi,
$insertapi,
$copyToClipboard,
$empty,
$snackbar,
$numtoString,
$formatNumber,
} = nuxtApp;
const store = useStore();
const lang = computed(() => store.lang);
const isVietnamese = computed(() => lang.value === "vi");
const dataLang = ref(store.common);
const emit = defineEmits(["close"]);
const props = defineProps({
row: Object,
api: String,
pagename: String,
});
const record = ref(props.row);
const findFieldName = (code) => {
let field = dataLang.value.find((v) => v.code === code);
return field;
};
const selected = (fieldName, value) => {
if (value) {
record.value.payment_status = value.id;
record.value.payment_status__code = value.code;
}
};
const handleUpdate = async () => {
try {
await $updateapi(props.api, record.value);
let ele = await $getdata(props.api, { id: record.value.id }, undefined, true);
$updatepage(props.pagename, ele);
$snackbar(isVietnamese.value ? "Cập nhật thành công" : "Update successful");
emit("close");
} catch (error) {
console.error("Error updating data:", error);
}
};
onMounted(async () => {
record.value = await $getdata(props.api, { id: record.value.id }, undefined, true);
});
</script>

View File

@@ -0,0 +1,262 @@
<template>
<div :id="docid">
<!-- Loading state -->
<div v-if="isLoading" class="has-text-centered mt-5 mb-5" style="min-height: 500px">
<button class="button is-primary is-loading is-large"></button>
<p class="mt-4 has-text-primary has-text-weight-semibold">
{{ isVietnamese ? 'Đang tải hợp đồng...' : 'Loading contracts...' }}
</p>
</div>
<!-- No contract state -->
<div v-else-if="!hasContracts" class="has-text-centered mt-5 mb-5" style="min-height: 500px">
<article class="message is-primary">
<div class="message-body" style="font-size: 17px; text-align: left; color: black">
{{
isVietnamese
? "Chưa có hợp đồng. Vui lòng tạo giao dịch và hợp đồng trước."
: "No contract available. Please create transaction and contract first."
}}
</div>
</article>
</div>
<!-- Contracts list -->
<template v-else>
<!-- Tabs khi nhiều hợp đồng -->
<div class="tabs border-bottom" id="ignore" v-if="contractsList.length > 1">
<ul class="tabs-list">
<li class="tabs-item" style="border: none" v-for="(contract, index) in contractsList" :key="index"
:class="{ 'bg-primary has-text-white': activeContractIndex === index }" @click="switchContract(index)">
<a class="tabs-link">
<span>{{ contract.document[0]?.name || contract.document[0]?.en || `Contract ${index + 1}` }}</span>
</a>
</li>
</ul>
</div>
<!-- Contract content -->
<div v-if="currentContract && pdfFileUrl && hasValidDocument">
<div class="contract-content mt-2">
<iframe :src="`https://mozilla.github.io/pdf.js/web/viewer.html?file=${pdfFileUrl}`" width="100%"
height="90vh" scrolling="no" style="border: none; height: 75vh; top: 0; left: 0; right: 0; bottom: 0">
</iframe>
</div>
</div>
<!-- Download buttons -->
<div class="mt-4" id="ignore">
<button v-if="hasValidDocument" class="button is-primary has-text-white mr-4" @click="downloadDocx">
{{ isVietnamese ? "Tải file docx" : "Download contract as docx" }}
</button>
<button v-if="hasValidDocument" class="button is-primary has-text-white mr-4" @click="downloadPdf">
{{ isVietnamese ? "Tải file pdf" : "Download contract as pdf" }}
</button>
<p v-if="contractError" class="has-text-danger mt-2">
{{ contractError }}
</p>
</div>
</template>
</div>
</template>
<script>
import { useStore } from "@/stores/index";
export default {
setup() {
const store = useStore();
return { store };
},
props: {
contractId: {
type: [Number, String],
default: null
},
row: {
type: Object,
default: null
},
directDocument: {
type: Object,
default: null
}
},
emits: ["contractCreated", "update", "close", "dataevent"],
data() {
return {
docid: this.$id(),
contractsList: [],
activeContractIndex: 0,
isLoading: false,
contractError: null,
lang: this.store.lang,
isVietnamese: this.store.lang === "vi",
link: this.$getpath().indexOf("dev") >= 0 ? "dev.utopia.y99.vn" : "utopia.y99.vn",
pdfFileUrl: undefined,
};
},
computed: {
hasContracts() {
return this.contractsList && this.contractsList.length > 0;
},
currentContract() {
return this.hasContracts ? this.contractsList[this.activeContractIndex] : null;
},
hasValidDocument() {
if (!this.currentContract) return false;
return this.currentContract.document &&
this.currentContract.document.length > 0 &&
this.currentContract.document[0]?.pdf;
}
},
async created() {
try {
this.isLoading = true;
this.contractError = null;
if (this.directDocument) {
this.contractsList = [
{ document: [this.directDocument] }
];
this.updatePdfUrl(0);
return;
}
let contracts = [];
let fetchParams = null;
if (this.contractId) {
fetchParams = { id: this.contractId };
}
else if (this.row?.id) {
fetchParams = { transaction: this.row.id };
}
if (!fetchParams) {
throw new Error(
this.isVietnamese
? 'Không có ID hợp đồng hoặc transaction để tải.'
: 'No contract ID or transaction provided to load.'
);
}
contracts = await this.$getdata(
'contract',
fetchParams,
undefined
);
if (!contracts || contracts.length === 0) {
throw new Error(
this.isVietnamese
? 'Không tìm thấy hợp đồng.'
: 'Contract not found.'
);
}
this.contractsList = contracts;
console.log(this.contractsList);
if (this.hasContracts) {
this.updatePdfUrl(this.activeContractIndex);
}
} catch (error) {
console.error('Error loading contracts:', error);
this.contractError = error.message || (
this.isVietnamese
? 'Lỗi khi tải danh sách hợp đồng.'
: 'Error loading contracts list.'
);
this.contractsList = [];
} finally {
this.isLoading = false;
}
},
methods: {
updatePdfUrl(index) {
const contract = this.contractsList[index];
if (contract?.document && contract.document[0]?.pdf) {
this.pdfFileUrl = `${this.$getpath()}download-contract/${contract.document[0].pdf}`;
} else {
this.pdfFileUrl = undefined;
}
},
switchContract(index) {
this.activeContractIndex = index;
this.updatePdfUrl(index);
},
downloadDocx() {
if (!this.hasValidDocument) {
this.$snackbar(
this.isVietnamese ? "Không có file để tải" : "No file to download",
{ type: 'is-warning' }
);
return;
}
const filename = this.currentContract.document[0].file;
const url = `${this.$getpath()}download/?name=${filename}&type=contract`;
this.$download(url, filename);
},
downloadPdf() {
if (!this.hasValidDocument) {
this.$snackbar(
this.isVietnamese ? "Không có file để tải" : "No file to download",
{ type: 'is-warning' }
);
return;
}
const filename = this.currentContract.document[0].pdf;
const url = `${this.$getpath()}download/?name=${filename}&type=contract`;
this.$download(url, filename);
},
},
};
</script>
<style scoped>
.contract-content {
max-width: 95%;
margin: 0 auto;
font-size: 18px;
line-height: 1.5;
font-family: "Times New Roman", serif;
page-break-inside: avoid;
break-inside: avoid;
}
.tabs {
width: auto;
margin-bottom: 0;
}
.tabs-list {
border: none;
transition: all 0.3s ease;
}
.tabs-item {
transition: background-color 0.3s ease;
}
.tabs-item.bg-primary:hover a {
color: white !important;
}
.tabs-link {
padding: 6px 12px;
}
@media print {
.contract-content {
max-width: 100%;
}
}
</style>

View File

@@ -0,0 +1,423 @@
<template>
<div>
<!-- Loading Overlay -->
<div v-if="isLoading" class="loading-overlay">
<div class="loader"></div>
</div>
<!-- View Mode Toggle -->
<div class="mb-5 pb-3" style="border-bottom: 2px solid #e8e8e8;">
<div class="buttons has-addons ">
<button @click="viewMode = 'list'" :class="['button', viewMode === 'list' ? 'is-primary' : 'is-light']">
Danh sách
</button>
<button @click="viewMode = 'gallery'" :class="['button', viewMode === 'gallery' ? 'is-primary' : 'is-light']">
Thư viện
</button>
</div>
</div>
<!-- Phase Document Types List -->
<div v-if="phasedoctypes && phasedoctypes.length > 0">
<div v-for="doctype in phasedoctypes" :key="doctype.id" class="mb-6">
<!-- Document Type Header with Upload Button -->
<div class="level is-mobile mb-4">
<div class="level-left">
<div class="level-item">
<p class="is-size-6 has-text-weight-semibold has-text-primary">
{{ doctype.doctype__name }}
</p>
</div>
</div>
<div class="level-right">
<div class="level-item">
<FileUpload
v-if="$getEditRights()"
:type="['file', 'image', 'pdf']"
@files="(files) => handleUpload(files, doctype.doctype)"
position="right"
/>
</div>
</div>
</div>
<!-- List View -->
<div v-if="viewMode === 'list'">
<div v-if="getFilesByDocType(doctype.doctype).length > 0">
<div v-for="file in getFilesByDocType(doctype.doctype)" :key="file.id"
class="is-flex is-justify-content-space-between is-align-items-center py-3 px-4 has-background-warning has-text-white"
style="border-bottom: #e8e8e8 solid 1px; transition: all 0.2s ease; opacity: 0.95; cursor: pointer;"
@mouseenter="$event.currentTarget.style.opacity = '1'"
@mouseleave="$event.currentTarget.style.opacity = '0.95'">
<div style="flex: 1; min-width: 0;">
<p class="is-size-7 has-text-weight-semibold has-text-white mb-1" style="word-break: break-word;">
{{ file.name || file.file__name }}
</p>
<p class="is-size-7 has-text-white-bis">
{{ $formatFileSize(file.file__size) }} {{ $dayjs(file.create_time).format("DD/MM/YYYY HH:mm") }}
</p>
</div>
<div class="buttons are-small ml-3">
<button @click="viewFile(file)" class="button has-background-white has-text-primary ">
<span class="icon">
<SvgIcon v-bind="{
name: 'view.svg',
type: 'success',
size: 18,
}"></SvgIcon>
</span>
</button>
<button @click="downloadFile(file)" class="button has-background-white has-text-primary ">
<span class="icon">
<SvgIcon v-bind="{
name: 'download.svg',
type: 'success',
size: 18,
}"></SvgIcon>
</span>
</button>
<button @click="deleteFile(file.id)" class="button has-background-white has-text-danger ">
<span class="icon">
<SvgIcon v-bind="{
name: 'bin.svg',
type: 'danger',
size: 18,
}"></SvgIcon>
</span>
</button>
</div>
</div>
</div>
<div v-else class="has-text-grey-light is-size-7 has-text-centered py-5">
Chưa có file nào
</div>
</div>
<!-- Gallery View -->
<div v-if="viewMode === 'gallery'">
<div v-if="getFilesByDocType(doctype.doctype).length > 0" class="columns is-multiline is-variable is-2">
<div v-for="file in getFilesByDocType(doctype.doctype)" :key="file.id"
class="column is-half-tablet is-one-third-desktop">
<div class="has-background-warning has-text-white"
style="border-radius: 6px; overflow: hidden; height: 100%; display: flex; flex-direction: column; transition: all 0.3s ease; box-shadow: 0 2px 8px rgba(50, 115, 220, 0.2);"
@mouseenter="$event.currentTarget.style.transform = 'translateY(-4px)'; $event.currentTarget.style.boxShadow = '0 6px 16px rgba(50, 115, 220, 0.3)'"
@mouseleave="$event.currentTarget.style.transform = 'translateY(0)'; $event.currentTarget.style.boxShadow = '0 2px 8px rgba(50, 115, 220, 0.2)'">
<div
style="flex: 1; display: flex; align-items: center; justify-content: center; padding: 16px; background: rgba(255, 255, 255, 0.1); min-height: 140px;">
<div v-if="isImage(file.file__name)"
style="width: 100%; height: 100%; display: flex; align-items: center; justify-content: center;">
<img :src="`${$getpath()}static/files/${file.file__file}`" :alt="file.file__name"
style="max-width: 100%; max-height: 100%; object-fit: contain;">
</div>
<div v-else class="has-text-white-ter" style="font-size: 48px; line-height: 1;">
FILE
</div>
</div>
<div style="padding: 12px 16px;">
<p class="is-size-7 has-text-weight-semibold has-text-white mb-1" :title="file.file__name"
style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">
{{ file.file__name }}
</p>
<p class="is-size-7 has-text-white-bis mb-3">{{ $formatFileSize(file.file__size) }}</p>
<div class="buttons are-small is-centered">
<button @click="viewFile(file)" class="button has-background-white has-text-primary ">
<span class="icon">
<SvgIcon v-bind="{
name: 'view.svg',
type: 'success',
size: 18,
}"></SvgIcon>
</span>
</button>
<button @click="downloadFile(file)" class="button has-background-white has-text-primary ">
<span class="icon">
<SvgIcon v-bind="{
name: 'download.svg',
type: 'success',
size: 18,
}"></SvgIcon>
</span>
</button>
<button @click="deleteFile(file.id)" class="button has-background-white has-text-danger ">
<span class="icon">
<SvgIcon v-bind="{
name: 'bin.svg',
type: 'danger',
size: 18,
}"></SvgIcon>
</span>
</button>
</div>
</div>
</div>
</div>
</div>
<div v-else class="has-text-grey-light is-size-7 has-text-centered py-5">
Chưa có file nào
</div>
</div>
</div>
</div>
<!-- If no phase doctypes -->
<div v-else-if="!isLoading" class="has-text-centered py-6">
<p class="has-text-grey-light is-size-7">Chưa có loại tài liệu được định nghĩa cho giai đoạn này.</p>
</div>
<Modal @close="showmodal = undefined" @modalevent="handleModalEvent" v-bind="showmodal" v-if="showmodal"></Modal>
</div>
</template>
<script>
export default {
name: "ContractPaymentUpload",
setup() {
const { $formatFileSize, $dayjs, $getpath } = useNuxtApp();
return { $formatFileSize, $dayjs, $getpath };
},
props: {
row: {
type: Object,
required: true
},
},
data() {
return {
transaction: null,
files: [],
isLoading: false,
showmodal: undefined,
phasedoctypes: [],
viewMode: 'list',
};
},
async created() {
const { $getdata } = useNuxtApp();
this.isLoading = true;
try {
const transactionId = this.row.id;
this.transaction = await $getdata("transaction", { id: transactionId }, undefined, true);
if (this.transaction?.phase) {
await this.fetchPhaseDoctypes();
}
await this.fetchFiles();
} catch (error) {
console.error("Error during component creation:", error);
} finally {
this.isLoading = false;
}
},
methods: {
async fetchPhaseDoctypes() {
const { $getdata } = useNuxtApp();
if (!this.transaction?.phase) return;
try {
const phasedoctypesData = await $getdata('phasedoctype', {
phase: this.transaction.phase,
}, undefined, false);
if (phasedoctypesData) {
this.phasedoctypes = Array.isArray(phasedoctypesData) ? phasedoctypesData : [phasedoctypesData];
} else {
this.phasedoctypes = [];
}
} catch (error) {
console.error("Lỗi khi tải phase doctypes:", error);
this.phasedoctypes = [];
}
},
async fetchFiles() {
const { $getdata } = useNuxtApp();
if (!this.row.id) return;
this.isLoading = true;
try {
const detail = await $getdata('reservation', {
id: this.transaction.txncurrent__detail
}, undefined, true)
const filesArray = await $getdata('transactionfile', {
txn_detail: detail.id,
}, undefined, false);
if (filesArray) {
this.files = (Array.isArray(filesArray) ? filesArray : [filesArray]).sort((a, b) => new Date(b.create_time) - new Date(a.create_time));
} else {
this.files = [];
}
} catch (error) {
console.error("Lỗi khi tải danh sách file:", error);
this.files = [];
} finally {
this.isLoading = false;
}
},
getFilesByDocType(docTypeId) {
return this.files.filter(file => file.file__doc_type === docTypeId || (file.file__doc_type == null && docTypeId == null));
},
getFileExtension(fileName) {
return fileName ? fileName.split('.').pop().toLowerCase() : 'file';
},
isImage(fileName) {
const ext = this.getFileExtension(fileName);
return ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'].includes(ext);
},
isViewableDocument(fileName) {
const ext = this.getFileExtension(fileName);
return ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'].includes(ext);
},
async handleUpload(uploadedFiles, docTypeId) {
if (!uploadedFiles || uploadedFiles.length === 0) return;
this.isLoading = true;
const { $patchapi, $getdata, $insertapi } = useNuxtApp();
try {
for (const fileRecord of uploadedFiles) {
if (docTypeId) {
await $patchapi('file', {
id: fileRecord.id,
doc_type: docTypeId
});
}
const detail = await $getdata('reservation', {
id: this.transaction.txncurrent__detail,
}, undefined, true)
const payload = {
txn_detail: detail.id,
file: fileRecord.id,
phase: this.transaction?.phase,
};
const result = await $insertapi("transactionfile", payload);
if (result && result.error) {
throw new Error(result.error || "Lưu file không thành công.");
}
}
await this.fetchFiles();
this.$emit('upload-completed');
} catch (error) {
console.error("Lỗi khi lưu file:", error);
alert("Đã xảy ra lỗi khi tải file lên. Vui lòng thử lại.");
} finally {
this.isLoading = false;
}
},
downloadFile(file) {
const { $getpath } = useNuxtApp();
const filePath = file.file__file || file.file;
if (!filePath) return;
const link = document.createElement('a');
link.href = `${$getpath()}static/files/${filePath}`;
link.download = file.file__name || 'download';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
},
deleteFile(fileId) {
this.showmodal = {
component: 'dialog/Confirm',
title: 'Xác nhận xóa',
height: '10vh',
width: '40%',
vbind: {
content: 'Bạn có chắc chắn muốn xóa file này không?'
},
onConfirm: async () => {
this.isLoading = true;
const { $deleteapi } = useNuxtApp();
try {
const result = await $deleteapi("transactionfile", fileId);
if (result && !result.error) {
await this.fetchFiles();
} else {
throw new Error(result.error || "Xóa file không thành công.");
}
} catch (error) {
console.error("Lỗi khi xóa file:", error);
alert("Đã xảy ra lỗi khi xóa file. Vui lòng thử lại.");
} finally {
this.isLoading = false;
}
}
};
},
handleModalEvent(event) {
if (event.name === 'confirm' && typeof this.showmodal?.onConfirm === 'function') {
this.showmodal.onConfirm();
}
},
viewFile(file) {
const { $getpath } = useNuxtApp();
const fileName = file.file__name || '';
const filePath = file.file__file || file.file;
if (!filePath) return;
const isImageFile = this.isImage(fileName);
const isViewable = this.isViewableDocument(fileName);
const fileUrl = `${$getpath()}static/files/${filePath}`;
if (isImageFile) {
this.showmodal = {
title: fileName,
component: 'media/ChipImage',
vbind: {
extend: false,
file: file,
image: fileUrl,
show: ['download', 'delete']
}
};
} else if (isViewable) {
// Mở Google Viewer trực tiếp trong tab mới
const viewerUrl = `https://docs.google.com/gview?url=${fileUrl}&embedded=false`;
window.open(viewerUrl, '_blank');
} else {
this.downloadFile(file);
}
},
}
};
</script>
<style scoped>
.loader {
border: 4px solid #f3f3f3;
border-top: 4px solid #3273dc;
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
}
.loading-overlay {
position: fixed;
inset: 0;
background-color: rgba(255, 255, 255, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
</style>

View File

@@ -0,0 +1,4 @@
<!-- Viewer: display when click tem from another dealer -->
<template>
<p>Rất tiếc, bạn hiện chưa quyền xem thông tin sản phẩm này.</p>
</template>

View File

@@ -0,0 +1,881 @@
<template>
<div v-if="productData" class="grid px-3">
<div class="cell is-col-span-12">
<div v-if="filteredPolicies.length > 1 && !policyId && !isPrecalculated" class="tabs is-boxed mb-4">
<ul>
<li v-for="pol in filteredPolicies" :key="pol.id" :class="{ 'is-active': activeTab === pol.id }">
<a @click="$emit('policy-selected', pol)">
<span v-if="activeTab === pol.id" class="has-text-weight-bold">{{ pol.code }}</span>
<span v-else>{{ pol.code }}</span>
</a>
</li>
</ul>
</div>
<div v-else-if="filteredPolicies.length === 0" class="notification is-light is-size-6">
Không chính sách thanh toán.
</div>
<div id="schedule-content">
<div v-if="selectedPolicy" id="print-area" :class="{ 'is-loading': isLoadingPlans }">
<div class="mb-4 is-flex is-justify-content-space-between is-align-items-center">
<h3 class="title is-4 has-text-primary mb-1">
{{ selectedPolicy.name }}
</h3>
<div>
<span class="button is-white">
<span class="has-text-weight-semibold">Đơn vị: VNĐ</span>
</span>
<button class="button is-light" @click="$emit('print')" id="ignore-print">
<span class="is-size-6">In</span>
</button>
</div>
</div>
<div style="border-bottom: 1px solid #eee;"></div>
<div class="fixed-grid has-4-cols-mobile has-7-cols-desktop">
<div class="grid">
<div class="cell is-col-span-6-mobile is-col-span-1-desktop">
<p class="is-size-6 has-text-weight-bold mb-1">Sản phẩm</p>
<p class="has-text-primary has-text-weight-medium">{{ productData.trade_code || productData.code }}</p>
</div>
<div class="cell is-col-span-6-mobile is-col-span-1-desktop">
<p class="is-size-6 has-text-weight-bold mb-1">Giá niêm yết</p>
<p class="has-text-primary">{{ $numtoString(originPrice) }}</p>
</div>
<div class="cell is-col-span-6-mobile is-col-span-1-desktop">
<p class="is-size-6 has-text-weight-bold mb-1">Tổng chiết khấu</p>
<p class="has-text-danger has-text-weight-bold">{{ $numtoString(discountValueDisplay) }}</p>
</div>
<div class="cell is-col-span-6-mobile is-col-span-1-desktop">
<p class="is-size-6 has-text-weight-bold mb-1">Giá sau chiết khấu</p>
<p class="has-text-black has-text-weight-bold">{{ $numtoString(priceAfterDiscount) }}</p>
</div>
<div v-if="selectedPolicy.contract_allocation_percentage < 100"class="cell is-col-span-6-mobile is-col-span-1-desktop">
<p class="is-size-6 has-text-weight-bold mb-1">Giá trị bảo đảm</p>
<p class="has-text-primary">{{ $numtoString(allocatedPrice) }}</p>
</div>
<div class="cell is-col-span-6-mobile is-col-span-1-desktop">
<p class="is-size-6 has-text-weight-bold mb-1">Đặt cọc</p>
<p class="has-text-primary">{{ $numtoString(selectedPolicy.deposit) }}</p>
</div>
<div class="cell is-col-span-6-mobile is-col-span-1-desktop">
<p class="is-size-6 has-text-weight-bold mb-1">Khách hàng</p>
<p v-if="selectedCustomer" class="has-text-primary has-text-weight-medium">{{ selectedCustomer.code }} -
{{ selectedCustomer.fullname }}</p>
<p v-else class="has-text-grey is-italic is-size-6">Chưa chọn</p>
</div>
</div>
</div>
<div style="border-bottom: 1px solid #eee;"></div>
<div v-if="detailedDiscounts.length > 0" class="mt-4 mb-4">
<p class="has-text-weight-bold is-size-5 mb-2 has-text-primary is-underlined">
CHI TIẾT CHIẾT KHẤU:
</p>
<table class="table is-fullwidth is-bordered is-narrow">
<thead>
<tr class="has-background-primary">
<th class="has-background-primary has-text-white" colspan="2">Diễn giải chiết khấu</th>
<th class="has-background-primary has-text-right has-text-white" width="15%">Giá trị</th>
<th class="has-background-primary has-text-right has-text-white" width="20%">Thành tiền</th>
<th class="has-background-primary has-text-right has-text-white" width="20%">Còn lại</th>
</tr>
</thead>
<tbody>
<tr class="has-text-grey-light">
<td colspan="4" class="has-text-right pt-1 pb-1">Giá gốc</td>
<td class="has-text-right has-text-weight-bold pt-1 pb-1">{{ $numtoString(originPrice) }}</td>
</tr>
<tr v-for="(item, idx) in detailedDiscounts" :key="`discount-${idx}`">
<td width="5%" class="has-text-centered">{{ idx + 1 }}</td>
<td><span class="has-text-weight-semibold">{{ item.name }}</span> <span
class="tag is-primary has-text-white is-rounded border ml-1">{{ item.code }}</span></td>
<td class="has-text-right">{{ item.customType === 1 ? item.customValue + '%' :
$numtoString(item.customValue) }}</td>
<td class="has-text-right has-text-danger">-{{ $numtoString(item.amount) }}</td>
<td class="has-text-right has-text-primary">{{ $numtoString(item.remaining) }}</td>
</tr>
</tbody>
</table>
</div>
<div v-if="enableEarlyPayment && earlyPaymentCycles > 0" class="mt-4">
<p class="has-text-weight-bold is-size-5 mb-2 has-text-primary is-underlined">
BẢNG DÙNG TIỀN THEO CHÍNH SÁCH BÁN HÀNG
</p>
<table class="table is-fullwidth is-hoverable is-bordered is-size-6">
<thead>
<tr class="has-background-primary">
<th class="has-background-primary has-text-white">Tiến độ</th>
<th class="has-background-primary has-text-white has-text-right">Số tiền TT (VND)</th>
<th class="has-background-primary has-text-white has-text-right">Ngày đến hạn TT</th>
<th class="has-background-primary has-text-white has-text-right">Số ngày</th>
<th class="has-background-primary has-text-white has-text-right">Tỷ lệ thanh toán</th>
<th class="has-background-primary has-text-white">Ghi chú</th>
</tr>
</thead>
<tbody>
<tr>
<td>0</td>
<td class="has-text-right">{{ $numtoString(selectedPolicy.deposit) }}</td>
<td class="has-text-right">-</td>
<td class="has-text-right">-</td>
<td class="has-text-right">-</td>
<td>Tiền đặt cọc</td>
</tr>
<tr v-for="plan in enhancedCashFlowPlans" :key="plan.id">
<td>{{ plan.cycle }}</td>
<td class="has-text-right">{{ $numtoString(plan.originalCalculatedAmount) }}</td>
<td class="has-text-right">{{ plan.dueDate }}</td>
<td class="has-text-right">{{ plan.days }}</td>
<td class="has-text-right">{{ plan.displayValue }}</td>
<td>{{ plan.payment_note }}</td>
</tr>
</tbody>
<tfoot>
<tr class="has-background-light">
<th colspan="1" class="has-text-right has-text-weight-bold">Tổng cộng</th>
<th class="has-text-right has-text-primary has-text-weight-bold">{{ $numtoString(priceAfterDiscount) }}</th>
<th colspan="4"></th>
</tr>
</tfoot>
</table>
</div>
<div v-if="selectedPolicy && selectedPolicy.method === 1 && selectedPolicy.contract_allocation_percentage == 100" class="mb-4 mt-4">
<div class="level is-mobile mb-3">
<div class="level-left">
<label class="checkbox" id="ignore-print">
<a class="mr-5" @click="doTick()">
<SvgIcon v-bind="{name: enableEarlyPayment? 'check4.svg' : 'uncheck.svg', type: 'primary', size: 28}" />
</a>
<span class="is-size-5 has-text-weight-semibold has-text-primary mr-5">
Thanh toán sớm
(2 - {{ Math.max(2, plansToRender.length) }}) kỳ
</span>
</label>
<transition name="fade" id="ignore-print">
<div v-if="enableEarlyPayment && plansToRender.length >= 2" class="field">
<div class="control">
<input class="input" type="number" v-model.number="earlyPaymentCycles" :min="2"
:max="Math.max(2, plansToRender.length)" @change="updateEarlyPaymentPlans">
</div>
</div>
</transition>
</div>
</div>
<div v-if="enableEarlyPayment && earlyPaymentCycles > 0" class="mt-4">
<p class="has-text-weight-bold is-size-5 mb-2 has-text-primary is-underlined">
BẢNG DÙNG TIỀN THEO CHƯƠNG TRÌNH THANH TOÁN SỚM BẰNG VỐN TỰ
</p>
<table class="table is-fullwidth is-hoverable is-bordered is-size-6">
<thead>
<tr class="has-background-primary">
<th class="has-background-primary has-text-white">Tiến độ</th>
<th class="has-background-primary has-text-white has-text-right">Số tiền TT (VND)</th>
<th class="has-background-primary has-text-white has-text-right">Ngày đến hạn TT</th>
<th class="has-background-primary has-text-white has-text-right">Ngày TT thực tế</th>
<th class="has-background-primary has-text-white has-text-right">Số ngày TT trước hạn</th>
<th class="has-background-primary has-text-white has-text-right">Lãi suất/ngày</th>
<th class="has-background-primary has-text-white has-text-right">Số tiền Chiết khấu TT (VND)</th>
<th class="has-background-primary has-text-white">Ghi chú</th>
</tr>
</thead>
<tbody>
<tr>
<td>0</td>
<td class="has-text-right">{{ $numtoString(selectedPolicy.deposit) }}</td>
<td class="has-text-right">-</td>
<td class="has-text-right">-</td>
<td class="has-text-right">-</td>
<td class="has-text-right">-</td>
<td class="has-text-right has-text-danger">0</td>
<td>Tiền đặt cọc</td>
</tr>
<tr v-for="plan in enhancedCashFlowPlans" :key="plan.id">
<td>{{ plan.cycle }}</td>
<td class="has-text-right">{{ $numtoString(plan.originalCalculatedAmount) }}</td>
<td class="has-text-right">{{ plan.dueDate }}</td>
<td class="has-text-right">
<span v-if="plan.isEarly">{{ plan.actualDueDate }}</span>
<span v-else>-</span>
</td>
<td class="has-text-right">
<span v-if="plan.isEarly">{{ plan.days }}</span>
<span v-else>-</span>
</td>
<td class="has-text-right">
<span v-if="plan.isEarly">0.019%</span>
<span v-else>-</span>
</td>
<td class="has-text-right has-text-danger">{{ $numtoString(plan.discountAmount) }}</td>
<td>{{ plan.payment_note }}</td>
</tr>
</tbody>
<tfoot>
<tr class="has-background-light">
<th colspan="6" class="has-text-right has-text-weight-bold">Tổng cộng</th>
<th class="has-text-right has-text-danger has-text-weight-bold">{{ $numtoString(totalEarlyDiscount) }}</th>
<th></th>
</tr>
</tfoot>
</table>
</div>
</div>
<div class="" style="border-top: 1px solid #eee;">
<div class="level is-mobile is-size-6 m-0">
<div class="level-right">
<div class="level-item">
<span class="is-uppercase is-size-4 has-text-weight-semibold">Tổng cộng:&nbsp;</span>
<div class="is-flex is-align-items-center is-flex-wrap-wrap">
<span class="has-text-success has-text-weight-bold is-size-4">
{{ $numtoString(allocatedPrice) }}
</span>
<span v-if="totalEarlyDiscount > 0" class="has-text-danger has-text-weight-bold is-size-4 ml-3">
- {{ $numtoString(totalEarlyDiscount) }}
</span>
<span v-if="totalEarlyDiscount > 0" class="has-text-success has-text-weight-bold is-size-4 ml-3">
= {{ $numtoString(totalRealPayment) }}
</span>
</div>
</div>
</div>
</div>
</div>
<div v-if="displayPaymentPlans.length > 0">
<div class="level m-0 is-mobile mt-4">
<div class="level-left">
<p class="has-text-weight-bold is-size-5 mb-2 has-text-primary is-underlined">
LỊCH THANH TOÁN
</p>
<span v-if="baseDate" class="tag is-info is-light ml-2">
Từ ngày: {{ formatDate(baseDate) }}
</span>
</div>
<div class="level-right" id="ignore-print">
<div class="buttons are-small has-addons">
<button class="button" @click="viewMode = 'table'"
:class="viewMode === 'table' ? 'is-link is-selected' : 'is-light'">
<span class="is-size-6">Bảng</span>
</button>
<button class="button" @click="viewMode = 'list'"
:class="viewMode === 'list' ? 'is-link is-selected' : 'is-light'">
<span class="is-size-6">Thẻ</span>
</button>
</div>
</div>
</div>
<div v-if="viewMode === 'table'" class="table-container schedule-container">
<table class="table is-fullwidth is-hoverable is-size-6">
<thead>
<tr>
<th class="has-background-primary has-text-white has-font-weight-normal" style="border:none;">Đợt thanh toán</th>
<th class="has-background-primary has-text-white has-font-weight-normal" style="border:none;">Diễn giải</th>
<th class="has-background-primary has-text-white has-font-weight-normal has-text-right" style="border:none;">Tỷ lệ</th>
<th class="has-background-primary has-text-white has-font-weight-normal has-text-right" style="border:none;">Số tiền</th>
<th class="has-background-primary has-text-white has-font-weight-normal has-text-right" style="border:none;">Thời gian</th>
<th class="has-background-primary has-text-white has-font-weight-normal has-text-right" style="border:none;">Hạn thanh toán</th>
</tr>
</thead>
<tbody>
<tr v-for="(plan, index) in calculatedPlans" :key="plan.id || index" style="border-bottom: 1px solid #f5f5f5;">
<td class="is-vcentered" style="border:none;">
<span class="has-text-primary has-text-weight-medium">
Đợt {{ plan.displayCycle }}
</span>
<span v-if="plan.isEarlyPaymentMerged" class="tag is-warning is-light ml-1 is-size-6">
GỘP SỚM
</span>
</td>
<td class="is-vcentered" style="border:none;">
<div v-if="plan.isEarlyPaymentMerged" class="content is-size-6">
<p class="mb-1 has-text-weight-semibold">
Thanh toán sớm gộp
(Đợt {{ plan.mergedCycles.join(', ') }})
</p>
<p class="has-text-grey">{{ plan.payment_note || '-' }}</p>
</div>
<div v-else>
<span>{{ plan.payment_note || '-' }}</span>
<div v-if="plan.due_note" class="is-size-6 mt-1">{{ plan.due_note }}</div>
</div>
</td>
<td class="has-text-right is-vcentered" style="border:none;">
<span v-if="plan.isEarlyPaymentMerged">{{ plan.mergedCyclesRates }}</span>
<span v-else>{{ plan.displayValue }}</span>
</td>
<td class="has-text-right is-vcentered" style="border:none;">
<div v-if="plan.isEarlyPaymentMerged" class="content is-size-6">
<div class="is-size-6 has-text-info mb-1">
Tổng các đợt: {{ $numtoString(plan.mergedRawAmount) }}
</div>
<div class="is-size-6 has-text-danger mb-1">
- Số tiền chiết khấu sớm: {{ $numtoString(totalEarlyDiscount) }}
</div>
<div v-if="selectedPolicy && selectedPolicy.deposit > 0" class="is-size-6 has-text-danger mb-1">
- Đặt cọc: {{ $numtoString(selectedPolicy.deposit) }}
</div>
<span class="has-text-primary has-text-weight-bold">
Còn lại: {{ $numtoString(plan.calculatedAmount) }}
</span>
</div>
<div v-else-if="plan.isFirstPlan && selectedPolicy && selectedPolicy.deposit > 0">
<div class="is-size-6 has-text-primary mb-1">
{{ $numtoString(plan.amountBeforeDeposit) }}
</div>
<div class="is-size-6 has-text-danger mb-1">
- {{ $numtoString(selectedPolicy.deposit) }}
</div>
<span class="has-text-primary has-text-weight-bold">
Còn lại: {{ $numtoString(plan.calculatedAmount) }}
</span>
</div>
<span v-else class="has-text-primary has-text-weight-bold">{{ $numtoString(plan.calculatedAmount) }}</span>
</td>
<td class="has-text-right pr-0 is-vcentered" style="border:none;">
<span v-if="plan.days" class="has-text-success">{{ plan.days }} ngày</span>
<span v-else>-</span>
</td>
<td class="has-text-right pr-3 is-vcentered" style="border:none;">
<span v-if="plan.dueDate" class="has-text-success">{{ plan.dueDate }}</span>
<span v-else>-</span>
</td>
</tr>
</tbody>
</table>
</div>
<div v-else-if="viewMode === 'list'" class="schedule-container">
<div v-for="(plan, index) in calculatedPlans" :key="plan.id || index" class="mb-4 pr-2 pb-3" style="border-bottom: 2px solid #f5f5f5;" :class="plan.isEarlyPaymentMerged ? ' p-3' : ''">
<div class="level is-mobile mb-1">
<div class="level-left">
<span class="tag is-white p-0 mr-2 border">{{ plan.displayCycle }}</span>
<span class="has-text-primary has-text-weight-medium is-size-6">
Đợt {{ plan.displayCycle }}
</span>
<span v-if="plan.isEarlyPaymentMerged" class="tag is-warning is-light ml-1 is-size-6">
GỘP SỚM
</span>
</div>
<div class="level-right">
<div v-if="plan.isEarlyPaymentMerged" class="has-text-right">
<p class="has-text-info has-text-weight-bold is-size-6">{{ $numtoString(plan.calculatedAmount) }}</p>
<p class="has-text-grey is-size-6">Với chiết khấu sớm</p>
</div>
<div v-else-if="plan.isFirstPlan && selectedPolicy && selectedPolicy.deposit > 0" class="has-text-right">
<div class="is-size-6 has-text-grey">
{{ $numtoString(plan.amountBeforeDeposit) }} - {{ $numtoString(selectedPolicy.deposit) }}
</div>
<span class="has-text-primary has-text-weight-bold is-size-6">{{ $numtoString(plan.calculatedAmount) }}</span>
</div>
<span v-else class="has-text-primary has-text-weight-bold is-size-6">{{ $numtoString(plan.calculatedAmount) }}</span>
</div>
</div>
<div v-if="plan.isEarlyPaymentMerged" class="content is-size-6 mb-2">
<p class="has-text-weight-semibold">
Thanh toán sớm gộp
(Đợt {{ plan.mergedCycles.join(', ') }})
</p>
<p class="has-text-grey is-size-6 mt-1">{{ plan.mergedCyclesRates }}</p>
</div>
<div v-if="plan.payment_note" class="is-size-6 mb-1">
<span class="has-text-grey">Diễn giải:</span> {{ plan.payment_note }}
</div>
<div v-if="plan.due_note" class="is-size-6 mb-1">{{ plan.due_note }}</div>
<div class="level is-mobile is-size-6">
<div class="level-left">
<span v-if="plan.dueDate" class="has-text-success">
Hạn: {{ plan.dueDate }} - {{ plan.days }}ngày
</span>
<span v-else>-</span>
</div>
<div class="level-right">
<span v-if="!plan.isEarlyPaymentMerged">{{ plan.displayValue }}</span>
</div>
</div>
</div>
</div>
</div>
<div v-else class="section has-text-centered">
<p v-if="isLoadingPlans" class="is-size-6 has-text-info is-flex is-align-items-center is-gap-2">
<SvgIcon v-bind="{ name: 'loading.svg', type: 'primary', size: 18 }" />
<span>Đang tải kế hoạch...</span>
</p>
<p v-else class="is-size-6">Chưa dữ liệu kế hoạch</p>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
import { useStore } from "@/stores/index";
import dayjs from 'dayjs';
import { useNuxtApp } from '#app';
const EARLY_PAYMENT_DAILY_RATE = 0.019;
export default {
name: 'PaymentScheduleComplete',
props: {
productData: Object,
policies: Array,
activeTab: [String, Number],
selectedPolicy: Object,
originPrice: Number,
discountValueDisplay: Number,
priceAfterDiscount: Number,
selectedCustomer: Object,
detailedDiscounts: Array,
paymentPlans: {
type: Array,
default: () => []
},
allPaymentPlans: {
type: Array,
default: () => []
},
policyId: {
type: [String, Number],
default: null
},
baseDate: {
type: [String, Date],
default: null
},
isPrecalculated: {
type: Boolean,
default: false
}
},
emits: ['policy-selected', 'print', 'plans-loaded', 'early-payment-change', 'calculated-plans-change'],
setup() {
const store = useStore();
const { $getdata } = useNuxtApp();
return { store, $getdata };
},
data() {
return {
viewMode: 'table',
localPaymentPlans: [],
isLoadingPlans: false,
enableEarlyPayment: false,
earlyPaymentCycles: 0,
earlyPaymentDetails: [],
};
},
computed: {
filteredPolicies() {
if (!this.policies || this.policies.length === 0) return [];
if (this.policyId) {
const foundPolicy = this.policies.find(p => p.id === this.policyId || p.id == this.policyId);
return foundPolicy ? [foundPolicy] : [];
}
return this.policies;
},
calculationStartDate() {
if (this.baseDate) {
return dayjs(this.baseDate);
}
return dayjs();
},
plansToRender() {
if (this.paymentPlans && this.paymentPlans.length > 0) {
return this.paymentPlans;
}
return this.localPaymentPlans;
},
allocatedPrice() {
if (!this.selectedPolicy) {
return this.priceAfterDiscount;
}
const basePrice = this.priceAfterDiscount;
if (this.selectedPolicy.contract_allocation_percentage > 0) {
const allocation = Number(this.selectedPolicy.contract_allocation_percentage);
return (basePrice * allocation) / 100;
}
return basePrice;
},
hasEarlyPaymentDiscount() {
return this.enableEarlyPayment && this.earlyPaymentDetails && this.earlyPaymentDetails.length > 0;
},
totalEarlyDiscount() {
if (!this.hasEarlyPaymentDiscount) return 0;
return this.earlyPaymentDetails.reduce((sum, d) => sum + d.discountAmount, 0);
},
totalEarlyPayment() {
if (!this.hasEarlyPaymentDiscount) return 0;
return this.earlyPaymentDetails.reduce((sum, d) => sum + d.netAmount, 0);
},
mergedRawAmount() {
if (!this.hasEarlyPaymentDiscount) return 0;
return this.earlyPaymentDetails.reduce((sum, d) => sum + d.rawAmount, 0);
},
finalBalanceAfterEarlyDiscount() {
if (this.detailedDiscounts.length === 0) {
return this.originPrice - this.totalEarlyDiscount;
}
const lastBalance = this.detailedDiscounts[this.detailedDiscounts.length - 1].remaining;
return lastBalance - this.totalEarlyDiscount;
},
enhancedCashFlowPlans() {
if (!this.enableEarlyPayment || !this.plansToRender.length) return [];
const earlyDetailsByCycle = new Map(this.earlyPaymentDetails.map(d => [d.cycle, d]));
return this.plansToRender.map(plan => {
const originalAmount = plan.type === 1
? (this.allocatedPrice * Number(plan.value)) / 100
: Number(plan.value);
const earlyDetail = earlyDetailsByCycle.get(plan.cycle);
if (earlyDetail) {
return {
...plan,
isEarly: true,
originalCalculatedAmount: originalAmount,
discountAmount: earlyDetail.discountAmount,
netAmount: earlyDetail.netAmount,
actualDueDate: this.calculationStartDate.format('DD/MM/YYYY'),
dueDate: plan.days > 0 ? this.calculationStartDate.add(plan.days, 'day').format('DD/MM/YYYY') : null,
displayValue: plan.type === 1 ? `${plan.value}%` : this.$numtoString(plan.value)
};
} else {
return {
...plan,
isEarly: false,
originalCalculatedAmount: originalAmount,
discountAmount: 0,
netAmount: originalAmount,
actualDueDate: null,
dueDate: plan.days > 0 ? this.calculationStartDate.add(plan.days, 'day').format('DD/MM/YYYY') : null,
displayValue: plan.type === 1 ? `${plan.value}%` : this.$numtoString(plan.value)
};
}
});
},
displayPaymentPlans() {
if (!this.plansToRender.length || !this.selectedPolicy) return [];
if (!this.hasEarlyPaymentDiscount || this.earlyPaymentCycles === 0) {
let cycleCounter = 1;
return this.plansToRender.map((plan, index) => {
let calculatedAmount = plan.type === 1
? (this.allocatedPrice * Number(plan.value)) / 100
: Number(plan.value);
const isFirstPlan = index === 0;
const amountBeforeDeposit = calculatedAmount;
if (isFirstPlan && this.selectedPolicy && this.selectedPolicy.deposit > 0) {
calculatedAmount -= this.selectedPolicy.deposit;
}
let dueDate = null;
const daysDiff = plan.days || 0;
if (daysDiff > 0) {
const dueDateObj = this.calculationStartDate.add(daysDiff, 'day');
dueDate = dueDateObj.format('DD/MM/YYYY');
}
return {
...plan,
displayCycle: cycleCounter++,
calculatedAmount: calculatedAmount,
amountBeforeDeposit: amountBeforeDeposit,
displayValue: plan.type === 1 ? `${plan.value}%` : this.$numtoString(plan.value),
dueDate: dueDate,
isEarlyPaymentMerged: false,
isFirstPlan: isFirstPlan
};
});
}
const earlyPaymentCycles = this.earlyPaymentCycles;
const basePrice = this.allocatedPrice;
const displayPlans = [];
let cycleCounter = 1;
const mergedCyclesInfo = this.earlyPaymentDetails.map(d => {
return `Đợt ${d.cycle}: ${d.type === 1 ? d.value + '%' : this.$numtoString(d.value)}`;
}).join(' + ');
displayPlans.push({
cycle: earlyPaymentCycles,
displayCycle: cycleCounter++,
mergedCycles: Array.from({ length: this.earlyPaymentCycles }, (_, i) => i + 1),
mergedCyclesRates: mergedCyclesInfo,
mergedRawAmount: this.mergedRawAmount,
isEarlyPaymentMerged: true,
calculatedAmount: this.totalEarlyPayment - (this.selectedPolicy.deposit || 0),
days: 0,
dueDate: this.calculationStartDate.format('DD/MM/YYYY'),
payment_note: 'Thanh toán sớm gộp',
displayValue: '-',
isFirstPlan: true
});
for (let i = earlyPaymentCycles; i < this.plansToRender.length; i++) {
const plan = this.plansToRender[i];
let calculatedAmount = plan.type === 1
? (basePrice * Number(plan.value)) / 100
: Number(plan.value);
const amountBeforeDeposit = calculatedAmount;
let dueDate = null;
const daysDiff = plan.days || 0;
if (daysDiff > 0) {
const dueDateObj = this.calculationStartDate.add(daysDiff, 'day');
dueDate = dueDateObj.format('DD/MM/YYYY');
}
displayPlans.push({
...plan,
displayCycle: cycleCounter++,
calculatedAmount: calculatedAmount,
amountBeforeDeposit: amountBeforeDeposit,
displayValue: plan.type === 1 ? `${plan.value}%` : this.$numtoString(plan.value),
dueDate: dueDate,
isEarlyPaymentMerged: false,
isFirstPlan: false
});
}
return displayPlans;
},
calculatedPlans() {
if (this.isPrecalculated && this.paymentPlans && this.paymentPlans.length > 0) {
return this.paymentPlans.map((plan, index) => {
const dueDate = plan.due_days ? this.calculationStartDate.add(plan.due_days, 'day').format('DD/MM/YYYY') : null;
return {
...plan,
displayCycle: plan.cycle || (index + 1),
calculatedAmount: plan.amount,
displayValue: plan.is_early_merged ? 'Gộp sớm' : '-',
dueDate: dueDate,
days: plan.due_days,
payment_note: plan.note || '-',
isEarlyPaymentMerged: plan.is_early_merged,
mergedCycles: plan.merged_cycles,
mergedRawAmount: plan.raw_amount,
isFirstPlan: index === 0,
amountBeforeDeposit: plan.raw_amount
}
});
}
return this.displayPaymentPlans;
},
totalRealPayment() {
let total = this.allocatedPrice;
if (this.hasEarlyPaymentDiscount) {
total -= this.totalEarlyDiscount;
}
return total;
}
},
watch: {
calculatedPlans: {
handler(newVal) {
this.$emit('calculated-plans-change', newVal);
},
immediate: true,
deep: true
},
allocatedPrice: {
handler() {
if (this.enableEarlyPayment) {
this.updateEarlyPaymentPlans();
}
}
},
policyId: {
immediate: true,
handler(newPolicyId) {
if (newPolicyId && !this.paymentPlans.length && this.selectedPolicy) {
this.loadPaymentPlans(newPolicyId);
}
}
},
selectedPolicy: {
immediate: true,
handler(newPolicy) {
if (newPolicy && this.paymentPlans.length === 0 && this.localPaymentPlans.length === 0) {
const plans = this.allPaymentPlans.filter(p => p.policy === newPolicy.id);
if (plans.length > 0) {
plans.sort((a, b) => a.cycle - b.cycle);
this.localPaymentPlans = plans;
}
}
this.enableEarlyPayment = false;
this.earlyPaymentCycles = 0;
this.earlyPaymentDetails = [];
}
}
},
methods: {
doTick() {
console.log('Hello')
this.enableEarlyPayment = ! this.enableEarlyPayment
return this.enableEarlyPayment
},
formatDate(date) {
return dayjs(date).format('DD/MM/YYYY');
},
handleEarlyPaymentToggle() {
if (this.enableEarlyPayment) {
this.earlyPaymentCycles = Math.min(2, this.plansToRender.length);
this.updateEarlyPaymentPlans();
} else {
this.earlyPaymentCycles = 0;
this.earlyPaymentDetails = [];
this.$emit('early-payment-change', null);
}
},
updateEarlyPaymentPlans() {
if (!this.enableEarlyPayment || this.earlyPaymentCycles === 0) {
this.earlyPaymentDetails = [];
this.$emit('early-payment-change', null);
return;
}
this.earlyPaymentCycles = Math.max(2, Math.min(this.earlyPaymentCycles, this.plansToRender.length));
this.earlyPaymentDetails = this.plansToRender
.filter(plan => plan.cycle <= this.earlyPaymentCycles)
.map((plan) => {
const rawAmount = plan.type === 1
? (this.allocatedPrice * plan.value) / 100
: plan.value;
const days = plan.days || 0;
const discountAmount = (rawAmount * days * EARLY_PAYMENT_DAILY_RATE) / 100;
return {
cycle: plan.cycle,
type: plan.type,
value: plan.value,
days: days,
rawAmount: rawAmount,
discountAmount: discountAmount,
netAmount: rawAmount - discountAmount
};
});
this.$emit('early-payment-change', {
cycles: this.earlyPaymentCycles,
details: this.earlyPaymentDetails
});
},
async loadPaymentPlans(policyId) {
if (!policyId || this.isLoadingPlans) return;
this.isLoadingPlans = true;
this.localPaymentPlans = [];
try {
const plans = await this.$getdata("paymentplan", { policy: policyId, policy__enable: "True" }, undefined, false);
if (plans) {
plans.sort((a, b) => a.cycle - b.cycle);
}
this.localPaymentPlans = plans || [];
this.$emit('plans-loaded', this.localPaymentPlans);
} catch (error) {
console.error('Error loading payment plans:', error);
this.localPaymentPlans = [];
} finally {
this.isLoadingPlans = false;
}
}
}
};
</script>
<style scoped>
.table-container.schedule-container thead th {
position: sticky;
top: 0;
background: white;
z-index: 2;
border-bottom: 1px solid #dbdbdb !important;
}
.table-container {
max-height: 400px;
overflow-y: auto;
}
.border {
border: 1px solid #dbdbdb;
}
li.is-active a,
li a:hover {
color: white !important;
background-color: #204853 !important;
transition: all 0.3s ease;
}
.content {
display: block;
}
.content p {
margin: 0;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
tr,
td,
th {
page-break-inside: avoid !important;
}
@media print {
.schedule-container {
max-height: none;
overflow: visible;
}
.table-container.schedule-container thead th {
position: static;
}
#ignore-print {
display: none !important;
}
tr,
td,
th {
page-break-inside: avoid !important;
}
}
</style>

View File

@@ -0,0 +1,504 @@
<template>
<div v-if="productData" class="grid px-3">
<div class="cell is-col-span-12">
<div id="schedule-content">
<div v-if="selectedPolicy" id="print-area" :class="{ 'is-loading': isLoading }">
<!-- Header -->
<div class="is-flex is-justify-content-space-between is-align-items-center">
<h3 class="title is-4 has-text-primary mb-1">
{{ selectedPolicy.name }}
</h3>
<div>
<span class="button is-white">
<span class="has-text-weight-semibold">Đơn vị: VNĐ</span>
</span>
<button class="button is-light" @click="$emit('print')" id="ignore-print">
<span class="is-size-6">In</span>
</button>
</div>
</div>
<hr class="my-4" style="background-color: var(--bulma-background)"></hr>
<!-- Summary Information -->
<div class="fixed-grid has-4-cols-mobile has-7-cols-desktop">
<div class="grid">
<div class="cell is-col-span-6-mobile is-col-span-1-desktop">
<p class="is-size-6 has-text-weight-bold mb-1">Sản phẩm</p>
<p class="has-text-primary has-text-weight-medium">{{ productData.trade_code || productData.code }} <a
class="ml-4" id="ignore" @click="$copyToClipboard(productData.trade_code)">
<SvgIcon name="copy.svg" type="primary" :size="18" />
</a>
</p>
</div>
<div class="cell is-col-span-6-mobile is-col-span-1-desktop">
<p class="is-size-6 has-text-weight-bold mb-1">Giá niêm yết</p>
<p class="has-text-primary">{{ $numtoString(calculatorData.originPrice) }}</p>
</div>
<div class="cell is-col-span-6-mobile is-col-span-1-desktop">
<p class="is-size-6 has-text-weight-bold mb-1">Tổng chiết khấu</p>
<p class="has-text-danger has-text-weight-bold">{{ $numtoString(calculatorData.totalDiscount) }}</p>
</div>
<div class="cell is-col-span-6-mobile is-col-span-1-desktop">
<p class="is-size-6 has-text-weight-bold mb-1">Giá sau chiết khấu</p>
<p class="has-text-black has-text-weight-bold">{{ $numtoString(calculatorData.salePrice) }}</p>
</div>
<div v-if="selectedPolicy.contract_allocation_percentage < 100"
class="cell is-col-span-6-mobile is-col-span-1-desktop">
<p class="is-size-6 has-text-weight-bold mb-1">Giá trị bảo đảm</p>
<p class="has-text-primary">{{ $numtoString(calculatorData.allocatedPrice) }}</p>
</div>
<div v-if="totalPaid === 0" class="cell is-col-span-6-mobile is-col-span-1-desktop">
<p class="is-size-6 has-text-weight-bold mb-1">Đặt cọc</p>
<p class="has-text-primary">{{ $numtoString(selectedPolicy.deposit) }}</p>
</div>
<div class="cell is-col-span-6-mobile is-col-span-1-desktop">
<p class="is-size-6 has-text-weight-bold mb-1">Khách hàng</p>
<p v-if="selectedCustomer" class="has-text-primary has-text-weight-medium">
{{ selectedCustomer.code }} - {{ selectedCustomer.fullname }}
</p>
<p v-else class="has-text-grey is-italic is-size-6">Chưa chọn</p>
</div>
</div>
</div>
<hr class="my-4" style="background-color: var(--bulma-background)"></hr>
<!-- Detailed Discounts -->
<div v-if="calculatorData.detailedDiscounts && calculatorData.detailedDiscounts.length > 0" class="mt-4 mb-4">
<p class="has-text-weight-bold is-size-5 mb-2 has-text-primary is-underlined">
CHI TIẾT CHIẾT KHẤU:
</p>
<table class="table is-fullwidth is-hoverable is-size-6">
<thead>
<tr>
<th class="has-background-primary has-text-white has-font-weight-normal" style="border: none;"
colspan="2">Diễn giải chiết khấu</th>
<th class="has-background-primary has-text-white has-font-weight-normal has-text-right"
style="border: none;" width="15%">Giá trị</th>
<th class="has-background-primary has-text-white has-font-weight-normal has-text-right"
style="border: none;" width="20%">Thành tiền</th>
<th class="has-background-primary has-text-white has-font-weight-normal has-text-right"
style="border: none;" width="20%">Còn lại</th>
</tr>
</thead>
<tbody>
<tr style="border-bottom: 1px solid #f5f5f5;" class="has-text-grey-light">
<td colspan="4" class="has-text-right pt-1 pb-1">Giá gốc</td>
<td class="has-text-right has-text-weight-bold pt-1 pb-1">{{
$numtoString(calculatorData.originPrice) }}</td>
</tr>
<tr v-for="(item, idx) in calculatorData.detailedDiscounts" :key="`discount-${idx}`"
style="border-bottom: 1px solid #f5f5f5;">
<td width="5%" class="has-text-centered">{{ idx + 1 }}</td>
<td>
<span class="has-text-weight-semibold">{{ item.name }}</span>
<span class="tag is-primary has-text-white is-rounded border ml-1">{{ item.code }}</span>
</td>
<td class="has-text-right">{{ item.customType === 1 ? item.customValue + '%' :
$numtoString(item.customValue) }}</td>
<td class="has-text-right has-text-danger">-{{ $numtoString(item.amount) }}</td>
<td class="has-text-right has-text-primary">{{ $numtoString(item.remaining) }}</td>
</tr>
</tbody>
</table>
</div>
<!-- Early Payment Details -->
<div v-if="isEarlyPaymentActive" class="mt-4 mb-4">
<!-- Original Schedule -->
<p class="has-text-weight-bold is-size-5 mb-2 has-text-primary is-underlined">
LỊCH THANH TOÁN GỐC (THEO CHÍNH SÁCH)
</p>
<div class="table-container schedule-container mb-4">
<table class="table is-fullwidth is-hoverable is-size-6">
<thead>
<tr>
<th class="has-background-primary has-text-white has-font-weight-normal" style="border: none;">Đợt
</th>
<th class="has-background-primary has-text-white has-font-weight-normal has-text-right"
style="border: none;">Tỷ lệ</th>
<th class="has-background-primary has-text-white has-font-weight-normal has-text-right"
style="border: none;">Số tiền (VND)</th>
<th class="has-background-primary has-text-white has-font-weight-normal" style="border: none;">Ngày
bắt đầu</th>
<th class="has-background-primary has-text-white has-font-weight-normal" style="border: none;">Ngày
đến hạn</th>
<th class="has-background-primary has-text-white has-font-weight-normal has-text-right"
style="border: none;">Số ngày</th>
</tr>
</thead>
<tbody>
<tr v-for="(plan, index) in calculatorData.originalPaymentSchedule" :key="`orig-plan-${index}`"
style="border-bottom: 1px solid #f5f5f5;">
<td class="has-text-weight-semibold">Đợt {{ plan.cycle }}</td>
<td class="has-text-right">{{ plan.type === 1 ? `${plan.value}%` : '-' }}</td>
<td class="has-text-right">{{ $numtoString(plan.amount) }}</td>
<td>{{ formatDate(plan.from_date) }}</td>
<td>{{ formatDate(plan.to_date) }}</td>
<td class="has-text-right">{{ plan.days }}</td>
</tr>
</tbody>
</table>
</div>
<!-- Early Discount Calculation Details -->
<p class="has-text-weight-bold is-size-5 mb-2 has-text-primary is-underlined">
DIỄN GIẢI CHIẾT KHẤU THANH TOÁN SỚM
</p>
<div class="table-container schedule-container">
<table class="table is-fullwidth is-hoverable is-size-6">
<thead>
<tr>
<th class="has-background-primary has-text-white has-font-weight-normal" style="border: none;">Đợt
</th>
<th class="has-background-primary has-text-white has-font-weight-normal" style="border: none;">Hạn
TT Gốc</th>
<th class="has-background-primary has-text-white has-font-weight-normal" style="border: none;">Ngày
TT Thực Tế</th>
<th class="has-background-primary has-text-white has-font-weight-normal has-text-right"
style="border: none;">Số tiền gốc</th>
<th class="has-background-primary has-text-white has-font-weight-normal has-text-right"
style="border: none;">Số ngày TT sớm</th>
<th class="has-background-primary has-text-white has-font-weight-normal has-text-right"
style="border: none;">Tỷ lệ CK (%/ngày)</th>
<th class="has-background-primary has-text-white has-font-weight-normal has-text-right"
style="border: none;">Tiền chiết khấu</th>
</tr>
</thead>
<tbody>
<tr v-for="(item, idx) in calculatorData.earlyDiscountDetails" :key="`early-discount-${idx}`"
style="border-bottom: 1px solid #f5f5f5;">
<td>Đợt {{ item.cycle }}</td>
<td>{{ formatDate(item.original_payment_date) }}</td>
<td>{{ formatDate(item.actual_payment_date) }}</td>
<td class="has-text-right">{{ $numtoString(item.original_amount) }}</td>
<td class="has-text-right">{{ item.early_days }}</td>
<td class="has-text-right">{{ item.discount_rate }}</td>
<td class="has-text-right has-text-danger">-{{ $numtoString(item.discount_amount) }}</td>
</tr>
</tbody>
<tfoot>
<tr class="has-background-light">
<th colspan="6" class="has-text-right has-text-weight-bold">Tổng chiết khấu thanh toán sớm</th>
<th class="has-text-right has-text-weight-bold has-text-danger">-{{
$numtoString(totalEarlyDiscount) }}</th>
</tr>
</tfoot>
</table>
</div>
</div>
<!-- Payment Schedule Table -->
<div v-if="displaySchedule.length > 0" class="mt-4">
<div class="level m-0 mb-2 is-mobile">
<div class="level-left">
<p class="has-text-weight-bold is-size-5 has-text-primary is-underlined">
<span v-if="isEarlyPaymentActive">LỊCH THANH TOÁN CUỐI CÙNG</span>
<span v-else>LỊCH THANH TOÁN</span>
</p>
</div>
<div class="level-right" id="ignore-print">
<div class="buttons are-small has-addons">
<button class="button" @click="viewMode = 'table'"
:class="viewMode === 'table' ? 'is-link is-selected' : 'is-light'">
<span class="is-size-6">Bảng</span>
</button>
<button class="button" @click="viewMode = 'list'"
:class="viewMode === 'list' ? 'is-link is-selected' : 'is-light'">
<span class="is-size-6">Thẻ</span>
</button>
</div>
</div>
</div>
<!-- Table View -->
<div v-if="viewMode === 'table'" class="table-container schedule-container">
<table class="table is-fullwidth is-hoverable is-size-6">
<thead>
<tr>
<th class="has-background-primary has-text-white has-font-weight-normal" style="border: none;">Đợt
thanh toán</th>
<th class="has-background-primary has-text-white has-font-weight-normal has-text-right"
style="border: none;">Số tiền (VND)</th>
<th class="has-background-primary has-text-white has-font-weight-normal has-text-right"
style="border: none;">Đã thanh toán</th>
<th class="has-background-primary has-text-white has-font-weight-normal has-text-right"
style="border: none;">Còn phải TT</th>
<th class="has-background-primary has-text-white has-font-weight-normal" style="border: none;">Ngày
bắt đầu</th>
<th class="has-background-primary has-text-white has-font-weight-normal" style="border: none;">Ngày
đến hạn</th>
<th class="has-background-primary has-text-white has-font-weight-normal" style="border: none;">Trạng
thái</th>
</tr>
</thead>
<tbody>
<tr v-for="(plan, index) in displaySchedule" :key="`plan-${index}`"
style="border-bottom: 1px solid #f5f5f5;"
:class="plan.is_merged ? 'has-background-warning-light' : ''">
<td class="has-text-weight-semibold" :class="plan.is_merged ? 'has-text-warning' : ''">
Đợt {{ plan.cycle }}
<span v-if="plan.is_merged" class="tag is-warning is-light ml-1 is-size-7">GỘP SỚM</span>
</td>
<td class="has-text-right">
<div v-if="plan.is_merged" class="has-text-right">
<p class="has-text-grey" title="Tổng các đợt gốc">{{ $numtoString(totalOriginalEarlyAmount) }}
</p>
<p class="has-text-danger" title="Chiết khấu thanh toán sớm">- {{
$numtoString(totalEarlyDiscount) }}</p>
<hr class="my-1"
style="background: hsla(0, 0%, 0%, 0.2); margin-left: auto; width: 50%;">
<p class="has-text-weight-bold">{{ $numtoString(plan.amount) }}</p>
</div>
<span v-else>{{ $numtoString(plan.amount) }}</span>
</td>
<td class="has-text-right has-text-success">{{ $numtoString(plan.paid_amount) }}</td>
<td class="has-text-right has-text-danger">{{ $numtoString(plan.remain_amount) }}</td>
<td>{{ formatDate(plan.from_date) }}</td>
<td>{{ formatDate(plan.to_date) }}</td>
<td>
<span v-if="plan.status === 2" class="tag is-success">Đã thanh toán</span>
<span v-else class="tag is-warning">Chờ thanh toán</span>
</td>
</tr>
</tbody>
<tfoot>
<tr class="has-background-light">
<th class="has-text-right has-text-weight-bold">Tổng cộng</th>
<th class="has-text-right has-text-weight-bold">{{ $numtoString(totalAmount) }}</th>
<th class="has-text-right has-text-weight-bold has-text-success">{{ $numtoString(totalPaid) }}
</th>
<th class="has-text-right has-text-weight-bold has-text-danger">{{
$numtoString(calculatorData.totalRemaining) }}</th>
<th colspan="3"></th>
</tr>
</tfoot>
</table>
</div>
<!-- List View (Card) -->
<div v-else-if="viewMode === 'list'" class="schedule-container">
<div v-for="(plan, index) in displaySchedule" :key="`card-${index}`" class="card mb-4"
:class="plan.is_merged ? 'has-background-warning-light' : ''">
<div class="card-content">
<div class="level is-mobile mb-5">
<div class="level-left">
<div class="level-item">
<span class="tag is-primary" :class="plan.is_merged ? 'is-warning' : ''">Đợt {{ plan.cycle
}}</span>
<span v-if="plan.is_merged" class="tag is-warning is-light ml-1 is-size-7">GỘP SỚM</span>
</div>
</div>
<div class="level-right">
<div class="level-item has-text-weight-bold">
<div v-if="plan.is_merged" class="has-text-right">
<p class="has-text-grey" title="Tổng các đợt gốc">{{ $numtoString(totalOriginalEarlyAmount)
}}</p>
<p class="has-text-danger" title="Chiết khấu thanh toán sớm">- {{
$numtoString(totalEarlyDiscount) }}</p>
<hr class="my-1"
style="background: hsla(0, 0%, 0%, 0.2); margin-left: auto">
<p class="has-text-weight-bold">{{ $numtoString(plan.amount) }}</p>
</div>
<span v-else>{{ $numtoString(plan.amount) }}</span>
</div>
</div>
</div>
<div class="level is-mobile mb-1">
<div class="level-left">Đã thanh toán:</div>
<div class="level-right has-text-success">{{ $numtoString(plan.paid_amount) }}</div>
</div>
<div class="level is-mobile mb-1">
<div class="level-left">Còn phải TT:</div>
<div class="level-right has-text-danger">{{ $numtoString(plan.remain_amount) }}</div>
</div>
<div class="level is-mobile mb-1">
<div class="level-left">Từ ngày:</div>
<div class="level-right">{{ formatDate(plan.from_date) }}</div>
</div>
<div class="level is-mobile mb-1">
<div class="level-left">Đến hạn:</div>
<div class="level-right">{{ formatDate(plan.to_date) }}</div>
</div>
<div class="level is-mobile">
<div class="level-left">Trạng thái:</div>
<div class="level-right">
<span v-if="plan.status === 2" class="tag is-success">Đã thanh toán</span>
<span v-else class="tag is-warning">Chờ thanh toán</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Summary Footer -->
<div class="" style="border-top: 1px solid #eee;">
<div class="level is-mobile is-size-6 my-4">
<div class="level-right">
<div class="level-item">
<span class="is-uppercase is-size-4 has-text-weight-semibold">Tổng cộng:&nbsp;</span>
<span class="has-text-success has-text-weight-bold is-size-4">
{{ $numtoString(calculatorData.allocatedPrice) }}
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue';
import dayjs from 'dayjs';
// Props - CHỈ NHẬN DỮ LIỆU ĐÃ TÍNH TOÁN
const props = defineProps({
productData: {
type: Object,
default: null
},
selectedPolicy: {
type: Object,
default: null
},
selectedCustomer: {
type: Object,
default: null
},
calculatorData: {
type: Object,
required: true,
// Cấu trúc:
// {
// originPrice: number,
// totalDiscount: number,
// salePrice: number,
// allocatedPrice: number,
// originalPaymentSchedule: array,
// finalPaymentSchedule: array,
// earlyDiscountDetails: array,
// totalRemaining: number,
// detailedDiscounts: array,
// baseDate: Date
// }
},
isLoading: {
type: Boolean,
default: false
}
});
// Emits
const emit = defineEmits(['print']);
// Local state
const viewMode = ref('table');
// Computed - CHỈ HIỂN THỊ, KHÔNG TÍNH TOÁN
const displaySchedule = computed(() => {
return props.calculatorData?.finalPaymentSchedule || [];
});
const isEarlyPaymentActive = computed(() => {
return props.calculatorData.earlyDiscountDetails && props.calculatorData.earlyDiscountDetails.length > 0;
});
const totalEarlyDiscount = computed(() => {
return props.calculatorData.earlyDiscountDetails?.reduce((sum, item) => sum + item.discount_amount, 0) || 0;
});
const totalOriginalEarlyAmount = computed(() => {
return props.calculatorData.earlyDiscountDetails?.reduce((sum, item) => sum + item.original_amount, 0) || 0;
});
const totalAmount = computed(() => {
return displaySchedule.value.reduce((sum, plan) => sum + plan.amount, 0);
});
const totalPaid = computed(() => {
return displaySchedule.value.reduce((sum, plan) => sum + plan.paid_amount, 0);
});
const formatDate = (date) => {
if (!date) return '-';
return dayjs(date).format('DD/MM/YYYY');
};
</script>
<style scoped>
.table-container.schedule-container thead th {
position: sticky;
top: 0;
background: white;
z-index: 2;
border-bottom: 1px solid #dbdbdb !important;
}
.table-container {
max-height: 400px;
overflow-y: auto;
}
.border {
border: 1px solid #dbdbdb;
}
li.is-active a,
li a:hover {
color: white !important;
background-color: #204853 !important;
transition: all 0.3s ease;
}
.content {
display: block;
}
.content p {
margin: 0;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
tr,
td,
th,
.card {
page-break-inside: avoid !important;
}
@media print {
.schedule-container {
max-height: none;
overflow: visible;
}
.table-container.schedule-container thead th {
position: static;
}
#ignore-print {
display: none !important;
}
tr,
td,
th {
page-break-inside: avoid !important;
}
}
</style>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,251 @@
<template>
<div class="field is-grouped">
<div class="control mr-6">
<div class="buttons has-addons">
<button
:class="`button ${v.code === tab ? 'is-primary is-selected has-text-white' : ''}`"
v-for="v in tabs"
@click="changeTab(v)"
>
{{ v.name }}
</button>
</div>
</div>
<div class="control">
<div class="buttons has-addons">
<button
:class="`button ${v.code === option ? 'is-dark is-selected has-text-white' : ''}`"
v-for="v in options"
@click="changeOption(v)"
>
{{ v.name }}
</button>
</div>
</div>
</div>
<template v-if="option === 'your'">
<template v-if="tab === 'message'">
<div class="field is-grouped" v-for="(v, i) in message">
<div class="control is-expanded">
<textarea class="textarea" placeholder="" rows="3" v-model="v.text"></textarea>
</div>
<div class="control">
<a class="mr-3" @click="add()">
<SvgIcon v-bind="{ name: 'add1.png', type: 'primary', size: 20 }"></SvgIcon>
</a>
<a @click="remove(v, i)">
<SvgIcon v-bind="{ name: 'bin1.svg', type: 'danger', size: 20 }"></SvgIcon>
</a>
<p class="mt-2">
<a @click="copyContent(v.text)">
<SvgIcon v-bind="{ name: 'copy.svg', type: 'primary', size: 22 }"></SvgIcon>
</a>
</p>
</div>
</div>
<div class="mt-5">
<button class="button is-primary has-text-white" @click="update()">Cập nhật</button>
</div>
</template>
<template v-else-if="tab === 'image'">
<div class="field is-grouped mb-0">
<div class="control is-expanded"></div>
<div class="control">
<FileUpload v-bind="{ position: 'left' }" @files="getImages"></FileUpload>
</div>
</div>
<div class="field is-grouped is-grouped-multiline" v-if="image.length > 0">
<div class="control mb-2" v-for="(v, i) in image">
<ChipImage
style="width: 128px"
@remove="removeImage(v, i)"
v-bind="{ show: ['copy', 'download', 'delete'], file: v, image: `${$getpath()}static/files/${v.file}` }"
>
</ChipImage>
</div>
</div>
</template>
<template v-else-if="tab === 'file'">
<div class="field is-grouped mb-0">
<div class="control is-expanded"></div>
<div class="control">
<FileUpload v-bind="{ position: 'left', type: 'file' }" @files="getFiles"></FileUpload>
</div>
</div>
<FileShow @remove="removeFile" v-bind="{ files: file, show: { delete: 1 } }"></FileShow>
</template>
<template v-else-if="tab === 'link'">
<div class="field is-grouped" v-for="(v, i) in link">
<div class="control is-expanded">
<input class="input" placeholder="" v-model="v.link" />
</div>
<div class="control">
<a class="mr-3" @click="copyContent(v.link)">
<SvgIcon v-bind="{ name: 'copy.svg', type: 'primary', size: 20 }"></SvgIcon>
</a>
<a class="mr-3" :href="v.link" target="_blank">
<SvgIcon v-bind="{ name: 'open.svg', type: 'primary', size: 20 }"></SvgIcon>
</a>
<a class="mr-3" @click="addLink()">
<SvgIcon v-bind="{ name: 'add1.png', type: 'primary', size: 18 }"></SvgIcon>
</a>
<a @click="removeLink(v, i)">
<SvgIcon v-bind="{ name: 'bin1.svg', type: 'danger', size: 18 }"></SvgIcon>
</a>
</div>
</div>
<div class="mt-5">
<button class="button is-primary has-text-white" @click="update()">Cập nhật</button>
</div>
</template>
</template>
<template v-else-if="option === 'system'">
<template v-if="tab === 'message'">
<div v-if="message">
<div class="px-2 py-2 mb-2" style="border: 1px solid #e8e8e8" v-for="(v, i) in message">
<span class="mr-3">{{ v.text }}</span>
<a @click="copyContent(v.text)">
<SvgIcon v-bind="{ name: 'copy.svg', type: 'primary', size: 20 }"></SvgIcon>
</a>
</div>
</div>
</template>
<template v-else-if="tab === 'image'">
<div class="field is-grouped is-grouped-multiline" v-if="image.length > 0">
<div class="control mb-2" v-for="(v, i) in image">
<ChipImage
style="width: 128px"
v-bind="{ show: ['copy', 'download'], file: v, image: `${$getpath()}static/files/${v.file}` }"
>
</ChipImage>
</div>
</div>
</template>
<template v-else-if="tab === 'file'">
<FileShow v-bind="{ files: file }"></FileShow>
</template>
<template v-else-if="tab === 'link'">
<div class="px-2 py-2 mb-2" style="border: 1px solid #e8e8e8" v-for="(v, i) in link">
<a :href="v.link" target="_blank" class="mr-3">{{ v.link }}</a>
<a @click="copyContent(v.link)">
<SvgIcon v-bind="{ name: 'copy.svg', type: 'primary', size: 20 }"></SvgIcon>
</a>
</div>
</template>
</template>
</template>
<script setup>
const {
$id,
$copy,
$copyToClipboard,
$getdata,
$resetNull,
$findapi,
$insertapi,
$updateapi,
$remove,
$deleteapi,
$empty,
} = useNuxtApp();
import ChipImage from "~/components/media/ChipImage.vue";
import FileShow from "~/components/media/FileShow.vue";
const tabs = [
{ code: "message", name: "Tin nhắn" },
{ code: "image", name: "Hình ảnh" },
{ code: "file", name: "Tài liệu" },
{ code: "link", name: "Liên kết" },
];
const options = [
{ code: "system", name: "Của hệ thống" },
{ code: "your", name: "Của bạn" },
];
var props = defineProps({
pagename: String,
row: Object,
});
var message = ref();
var image = ref();
var file = ref();
var link = ref();
var tab = ref("message");
var record = await $getdata("useraction", { user: 1, action: props.row.id }, undefined, true);
var option = ref(record ? "your" : "system");
function getValue() {
if (option.value === "your") {
message.value = record ? $copy(record.message || [{ code: $id() }]) : [{ code: $id() }];
image.value = record ? $copy(record.image) : [];
file.value = record ? $copy(record.file) : [];
link.value = record ? $copy(record.link || [{ code: $id() }]) : [{ code: $id() }];
return;
}
message.value = $copy(props.row.message || []);
image.value = $copy(props.row.image || []);
file.value = $copy(props.row.file || []);
link.value = $copy(props.row.link || []);
}
// get values
getValue();
// next
function changeTab(v) {
tab.value = v.code;
}
function changeOption(v) {
option.value = v.code;
getValue();
}
function getFiles(files) {
let copy = $copy(file.value);
copy = copy.concat(files);
file.value = copy;
if (option.value === "your") update();
}
function getImages(images) {
let copy = $copy(image.value);
copy = copy.concat(images);
image.value = copy;
if (option.value === "your") update();
}
function copyContent(text) {
$copyToClipboard(text);
}
function add() {
message.value.push({ code: $id() });
}
function remove(i) {
message.value.splice(i, 1);
}
function addLink() {
link.value.push({ code: $id() });
}
function removeLink(i) {
link.value.splice(i, 1);
}
async function update() {
let data = record ? $resetNull(record) : null;
if (!data) data = { user: 1, action: props.row.id };
let arr = message.value.filter((v) => !$empty(v.text));
data.message = arr.length === 0 ? null : arr;
data.image = image.value;
data.file = file.value;
let arr1 = link.value.filter((v) => !$empty(v.link));
data.link = arr1.length === 0 ? null : arr1;
let api = $findapi("useraction");
record = data.id
? await $updateapi("useraction", data, api.params.values)
: await $insertapi("useraction", data, api.params.values);
getValue();
}
async function removeImage(v, i) {
let rs = await $deleteapi("file", v.id);
$remove(image.value, i);
if (option.value === "your") update();
}
async function removeFile(data) {
let v = data.v;
let i = data.i;
let rs = await $deleteapi("file", v.id);
$remove(file.value, i);
if (option.value === "your") update();
}
</script>

View File

@@ -0,0 +1,28 @@
<template>
<div :class="`cbox-${type}-${size} mx-0 px-0`" @click="$emit('justclick')" :style="image? 'border: none' : ''">
<figure class="image" v-if="image">
<img class="is-rounded" :src="`${$path()}download?name=${image}`">
</figure>
<div v-else>
<span>{{text}}</span>
</div>
</div>
</template>
<script>
export default {
props: ['text', 'image', 'type', 'size']
}
</script>
<style>
.cbox-findata-two {
font-size: 16px;
font-weight: bold;
width: 34px;
height: 34px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid #e4e4e4;
border-radius: 50%;
}
</style>

View File

@@ -0,0 +1,41 @@
<template>
<div @click="handleClick()" class="is-clickable">
<template v-if="count > 0">
<span class="dot-primary">
{{ count }}
</span>
</template>
<template v-else>
<span class="dot-primary">
+
</span>
</template>
</div>
</template>
<script>
export default {
props: ['row', 'countField', 'modalConfig'],
computed: {
count() {
return this.row[this.countField] || 0;
}
},
methods: {
handleClick() {
if (!this.modalConfig) return;
let config = this.$copy(this.modalConfig);
this.$emit('open', {
name: 'dataevent',
data: {
modal: config
}
});
}
}
}
</script>

View File

@@ -0,0 +1,135 @@
<!-- CountdownTimer.vue -->
<template>
<div class="countdown-wrapper">
<span v-if="isExpired" class="tag is-danger">
{{ isVietnamese ? 'Hết giờ' : 'Expired' }}
</span>
<span v-else class="tag" :class="tagClass">
<span class="countdown-text">{{ formattedTime }}</span>
</span>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { useStore } from '@/stores/index'
const props = defineProps({
dateValue: {
type: [String, Date],
required: true
},
format: {
type: String,
default: 'HH:mm:ss'
}
})
const store = useStore()
const { $dayjs } = useNuxtApp()
const timeRemaining = ref({
days: 0,
hours: 0,
minutes: 0,
seconds: 0
})
const isExpired = ref(false)
let intervalId = null
const isVietnamese = computed(() => store.lang === 'vi')
const tagClass = computed(() => {
const totalSeconds = timeRemaining.value.days * 86400 +
timeRemaining.value.hours * 3600 +
timeRemaining.value.minutes * 60 +
timeRemaining.value.seconds
if (totalSeconds <= 0) return 'is-danger'
if (totalSeconds <= 3600) return 'is-warning' // <= 1 hour
if (totalSeconds <= 86400) return 'is-info' // <= 1 day
return 'is-primary' // > 1 day
})
const formattedTime = computed(() => {
const { days, hours, minutes, seconds } = timeRemaining.value
if (days > 0) {
return isVietnamese
? `${days}d ${hours}h ${minutes}m`
: `${days}d ${hours}h ${minutes}m`
}
if (hours > 0) {
return isVietnamese
? `${hours}h ${minutes}m ${seconds}s`
: `${hours}h ${minutes}m ${seconds}s`
}
return isVietnamese
? `${minutes}m ${seconds}s`
: `${minutes}m ${seconds}s`
})
const calculateTimeRemaining = () => {
try {
const targetDate = $dayjs(props.dateValue)
const now = $dayjs()
if (now.isAfter(targetDate)) {
isExpired.value = true
timeRemaining.value = { days: 0, hours: 0, minutes: 0, seconds: 0 }
return
}
isExpired.value = false
const diff = targetDate.diff(now, 'second')
const days = Math.floor(diff / 86400)
const hours = Math.floor((diff % 86400) / 3600)
const minutes = Math.floor((diff % 3600) / 60)
const seconds = diff % 60
timeRemaining.value = { days, hours, minutes, seconds }
} catch (error) {
console.error('Error calculating countdown:', error)
isExpired.value = true
}
}
const startCountdown = () => {
calculateTimeRemaining()
if (intervalId) clearInterval(intervalId)
intervalId = setInterval(() => {
calculateTimeRemaining()
if (isExpired.value && intervalId) {
clearInterval(intervalId)
intervalId = null
}
}, 1000)
}
watch(() => props.dateValue, () => {
startCountdown()
}, { deep: true })
onMounted(() => {
startCountdown()
})
onBeforeUnmount(() => {
if (intervalId) {
clearInterval(intervalId)
}
})
</script>
<style scoped>
.countdown-wrapper {
display: inline-block;
}
</style>

View File

@@ -0,0 +1,64 @@
<template>
<ClientOnly>
<QuillEditor
v-model:content="content"
content-type="html"
theme="snow"
:toolbar="toolbarOptions"
@text-change="textChange"
:style="`font-size: 16px; height: ${props.height};`"
/>
</ClientOnly>
</template>
<script setup>
import { QuillEditor } from "@vueup/vue-quill";
import "@vueup/vue-quill/dist/vue-quill.snow.css";
const emit = defineEmits(["content", "modalevent"]);
const props = defineProps({
text: String,
row: Object,
pagename: String,
api: String,
height: {
type: String,
default: "450px",
},
});
// Custom toolbar options
const toolbarOptions = [
// 🔤 Font chữ
[{ font: [] }],
// 🔠 Cỡ chữ
[{ header: [1, 2, 3, 4, 5, 6, false] }],
// ✍️ Định dạng cơ bản
['bold', 'italic', 'underline', 'strike'],
// 🎨 Màu chữ & nền
[{ color: [] }, { background: [] }],
// 📐 Căn lề
[{ align: [] }],
// 📋 Danh sách
[{ list: 'ordered' }, { list: 'bullet' }],
// 🔗 Media
['link', 'image', 'video'],
['clean'], // Xóa định dạng
]
var content = props.text;
function textChange() {
emit("content", content);
emit("modalevent", { name: "content", data: content });
}
</script>

View File

@@ -0,0 +1,37 @@
<template>
<div class="control has-icons-left">
<input :class="`input ${error? 'is-danger' : ''} ${disabled? 'has-text-black' : ''}`" type="text"
:placeholder="placeholder || ''" v-model="value" @keyup="doCheck" :disabled="disabled || false">
<span class="icon is-left">
<SvgIcon v-bind="{name: 'email.svg', type: 'gray', size: 21}"></SvgIcon>
</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

@@ -0,0 +1,94 @@
<template>
<div class="control has-icons-left">
<input
:class="`input ${disabled ? 'has-text-black' : ''}`"
type="text"
:placeholder="placeholder || ''"
v-model="value"
@keyup="doCheck"
:disabled="disabled || false"
/>
<span class="icon is-left">
<SvgIcon v-bind="{ name: 'calculator.svg', type: 'gray', size: 22 }"></SvgIcon>
</span>
</div>
</template>
<script>
export default {
props: ["record", "attr", "placeholder", "disabled", "defaultValue"],
data() {
return {
value: this.getInitialValue(),
timer: undefined,
};
},
created() {
if (this.defaultValue) {
this.value = this.value !== undefined ? this.$numtoString(this.value) : this.$numtoString(0);
} else {
if (this.value !== undefined && this.value !== null) {
this.value = this.$numtoString(this.value);
} else {
this.value = "";
}
}
},
watch: {
record: function (newVal) {
this.value = this.$numtoString(this.record[this.attr]);
},
},
methods: {
getInitialValue() {
const recordValue = this.record ? this.record[this.attr] : undefined;
if (this.defaultValue) {
if (recordValue === null || recordValue === undefined || recordValue === "") {
return 0;
}
return this.$copy ? this.$copy(recordValue) : recordValue;
} else {
if (recordValue === null || recordValue === undefined || recordValue === "") {
return undefined;
}
return this.$copy ? this.$copy(recordValue) : recordValue;
}
},
getDisplayValue(recordValue) {
if (this.defaultValue) {
if (recordValue === null || recordValue === undefined || recordValue === "") {
return this.$numtoString(0);
}
return this.$numtoString(recordValue);
} else {
if (recordValue === null || recordValue === undefined || recordValue === "") {
return "";
}
return this.$numtoString(recordValue);
}
},
doCheck() {
if (this.timer) clearTimeout(this.timer);
this.timer = setTimeout(() => this.checkChange(), 500);
},
doChange() {
this.$emit("change", this.value);
},
checkChange() {
if (!this.$empty(this.value)) {
this.value = this.$numtoString(this.$formatNumber(this.value));
this.$emit("number", this.$formatNumber(this.value));
} else {
if (this.defaultValue) {
this.value = this.$numtoString(0);
this.$emit("number", 0);
} else {
this.value = "";
this.$emit("number", null);
}
}
},
},
};
</script>

View File

@@ -0,0 +1,55 @@
<template>
<div class="control has-icons-left">
<input
class="input"
type="text"
:placeholder="placeholder || ''"
v-model="value"
:disabled="disabled || false"
inputmode="numeric"
autocomplete="tel"
/>
<span class="icon is-left">
<SvgIcon v-bind="{name: 'phone.png', type: 'gray', size: 20}"></SvgIcon>
</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

@@ -0,0 +1,27 @@
<template>
<div class="field">
<div class="control">
<textarea v-model="record.note" class="textarea" name="note" placeholder="" rows="8"></textarea>
</div>
</div>
<div class="mt-4">
<button class="button is-primary has-text-white" @click="save()">
{{ $store.lang==='vi'? 'Lưu lại' : 'Save' }}
</button>
</div>
</template>
<script setup>
const emit = defineEmits(["close"])
const { $store, $getdata, $updateapi, $updatepage } = useNuxtApp();
const props = defineProps({
row: Object,
pagename: String
})
var record = await $getdata('application', {id: props.row.id}, undefined, true)
async function save() {
await $updateapi('application', record)
record = await $getdata('application', {id: props.row.id}, undefined, true)
$updatepage(props.pagename, record)
emit('close')
}
</script>

View File

@@ -0,0 +1,125 @@
<template>
<div>
<template v-if="data">
<article class="message is-findata" v-if="data.length===0">
<div class="message-body py-2 fs-16">
Chưa <b>ghi chú</b> nào được lưu
</div>
</article>
<template v-else>
<article class="media mt-0 mb-0" v-for="(v,i) in data">
<figure class="media-left">
<Avatarbox v-bind="{
text: v.user__fullname[0].toUpperCase(),
size: 'two',
type: 'primary'
}" />
</figure>
<div class="media-content">
<div>
<p class="fs-15">
{{ v.detail }}
</p>
<p class="mt-1 fs-14 has-text-grey">
<span class="icon-text">
<span>{{v.user__fullname}}</span>
<span class="ml-3">{{ $dayjs(v['create_time']).fromNow(true) }}</span>
<template v-if="login.id===v.user">
<a class="ml-3" @click="edit(v)">
<SvgIcon v-bind="{name: 'pen1.svg', type: 'gray', size: 20}"></SvgIcon>
</a>
<a class="ml-3" @click="askConfirm(v, i)">
<SvgIcon v-bind="{name: 'bin.svg', type: 'gray', size: 20}"></SvgIcon>
</a>
</template>
</span>
</p>
</div>
</div>
</article>
</template>
</template>
<div v-if="$getEditRights()" class="field is-grouped mt-3">
<div class="control is-expanded">
<textarea class="textarea" rows="2" placeholder="Viết ghi chú tại đây" v-model="detail"></textarea>
</div>
<div class="control">
<button class="button is-primary has-text-white" @click="save()">Lưu</button>
</div>
</div>
<Modal @close="showmodal=undefined" v-bind="showmodal" v-if="showmodal" @confirm="confirm()"></Modal>
</div>
</template>
<script>
export default {
props: ['row', 'api', 'pagename'],
data() {
return {
data: undefined,
detail: undefined,
vbind2: {image: undefined, text: 'ABC', size: 'two', type: 'findata'},
current: undefined,
showmodal: undefined,
obj: undefined
}
},
async created() {
if(!this.row) return
this.data = await this.$getdata(this.api, {ref: this.row.id})
},
computed: {
login: {
get: function() {return this.$store.login},
set: function(val) {this.$store.commit("updateLogin", {login: val})}
}
},
methods: {
async save() {
if(this.$empty(this.detail)) return this.$snackbar('Chưa nhập nội dung ghi chú')
let data = {user: this.$store.login.id, detail: this.detail, ref: this.row.id}
if(this.current) {
data = this.$copy(this.current)
data.detail = this.detail
}
let rs = data.id? await this.$updateapi(this.api, data) : await this.$insertapi(this.api, data)
if(!rs) return
this.detail = undefined
if(this.current) {
this.current = undefined
let idx = this.$findIndex(this.data, {id: rs.id})
this.$set(this.data, idx, rs)
} else {
this.data.push(rs)
let rows = this.$copy(this.$store[this.pagename].data)
let idx = this.$findIndex(rows, {id: this.row.id})
let copy = this.$copy(this.row)
copy.count_note += 1
rows[idx] = copy
this.$store.commit('updateState', {name: this.pagename, key: 'update', data: {data: rows}})
}
},
edit(v) {
this.current = this.$copy(v)
this.detail = v.detail
},
askConfirm(v, i) {
this.obj = {v: v, i: i}
this.showmodal = {component: `dialog/Confirm`,vbind: {content: 'Bạn có muốn xóa ghi chú này không?', duration: 10},
title: 'Xóa ghi chú', width: '500px', height: '100px'}
},
async confirm() {
let v = this.obj.v
let i = this.obj.i
let rs = await this.$deleteapi(this.api, v.id)
if(rs==='error') return
this.$delete(this.data, i)
let rows = this.$copy(this.$store[this.pagename].data)
let idx = this.$findIndex(rows, {id: this.row.id})
let copy = this.$copy(this.row)
copy.count_note -= 1
rows[idx] = copy
this.$store.commit('updateState', {name: this.pagename, key: 'update', data: {data: rows}})
}
}
}
</script>

View File

@@ -0,0 +1,18 @@
<template>
<span
v-if="row.count_note || $getEditRights()"
class="dot-primary"
@click="doClick()"
>{{ row.count_note || '+' }}</span>
</template>
<script>
export default {
props: ['row', 'api', 'pagename'],
methods: {
doClick() {
let obj = {component: 'common/NoteInfo', title: 'Ghi chú', width: '50%', vbind: {row: this.row, api: this.api, pagename: this.pagename}}
this.$emit('open', {name: 'dataevent', data: {modal: obj}})
}
}
}
</script>

View File

@@ -0,0 +1,54 @@
<template>
<div>
<p class="fsb-30">{{ text }}
<a class="ml-3" @click="copy()">
<SvgIcon v-bind="{name: 'copy.svg', type: 'primary', size: 24}"></SvgIcon>
</a>
</p>
<p class="buttons mt-4">
<button class="button is-primary" @click="call()">Call</button>
<button class="button is-primary" @click="sms()">SMS</button>
<button class="button is-primary" @click="openZalo()">Zalo</button>
</p>
<Modal @close="showmodal=undefined" v-bind="showmodal" v-if="showmodal"></Modal>
</div>
</template>
<script>
export default {
props: ['row', 'pagename'],
data() {
return {
text: undefined,
phone: this.row.customer__phone || this.row.party__phone || this.row.phone,
showmodal: undefined
}
},
created() {
var format = function(s) {
return `${s.slice(0,3)} ${s.slice(3,6)} ${s.slice(6, 20)}`
}
this.text = format(this.phone)
},
methods: {
call() {
window.open(`tel:${this.phone}`)
},
sms() {
window.open(`sms:${this.phone}`)
},
sendSms() {
let api = this.row.code.indexOf('CN')>=0? 'customersms' : undefined
if(this.row.code.indexOf('LN')>=0) api = 'loansms'
else if(this.row.code.indexOf('TS')>=0) api = 'collateralsms'
this.showmodal = {component: 'user/Sms', title: 'Nhắn tin SMS', width: '50%', height: '400px',
vbind: {row: this.row, pagename: this.pagename, api: api}}
},
copy() {
this.$copyToClipboard(this.phone)
},
openZalo() {
window.open(`https://zalo.me/${this.phone}`, '_blank')
}
}
}
</script>

View File

@@ -0,0 +1,25 @@
<template>
<span class="dot-primary" @click="onClick()">{{ row.count_product }}</span>
</template>
<script>
// use in Khách hàng -> Giao dịch (<DataView :setting='customer-all-transaction'/>)
export default {
props: ['row', 'api', 'pagename'],
methods: {
onClick() {
const obj = {
component: 'common/ProductInfo',
title: 'Sản phẩm',
width: '60%',
height: '400px',
vbind: {
row: this.row,
pagename: this.pagename
}
}
this.$emit('open', {name: 'dataevent', data: { modal: obj }})
}
}
}
</script>

View File

@@ -0,0 +1,21 @@
<script setup>
const props = defineProps({
row: Object,
pagename: String
});
const { $id } = useNuxtApp();
</script>
<template>
<div>
<DataView v-bind="{
setting: 'product-info',
pagename: $id(),
api: 'product',
params: {
filter: { prdbk__transaction__customer: props.row.id },
// copied from 02-connection.js
values: 'price_excluding_vat,prdbk__transaction__txncurrent__detail__status__name,locked_until,note,cart,cart__name,cart__code,cart__dealer,cart__dealer__code,cart__dealer__name,direction,type,zone_type,dealer,link,type__name,dealer__code,dealer__name,prdbk,prdbk__transaction__customer,prdbk__transaction,prdbk__transaction__policy__code,prdbk__transaction__sale_price,prdbk__transaction__discount_amount,prdbk__transaction__code,prdbk__transaction__customer__code,prdbk__transaction__customer__phone,prdbk__transaction__customer__fullname,prdbk__transaction__customer__legal_code,id,code,trade_code,land_lot_code,zone_code,zone_type__name,lot_area,building_area,total_built_area,number_of_floors,land_lot_size,origin_price,direction__name,villa_model,product_type,template_name,project,project__name,status,status__code,status__name,status__color,status__sale_status,status__sale_status__color,create_time,prdbk__transaction__amount_received,prdbk__transaction__amount_remain',
}
}" />
</div>
</template>

View File

@@ -0,0 +1,127 @@
<template>
<div class="has-text-centered">
<div class="mb-4">
<p v-if="row && row.fullname"><b>{{row.fullname}}</b></p>
</div>
<div class="mt-2 px-5 is-flex is-justify-content-center">
<ClientOnly>
<Qrcode
v-if="finalLink"
:key="finalLink"
id="qrcode"
:value="finalLink"
:size="300"
/>
</ClientOnly>
<div v-if="!finalLink" style="width: 300px; height: 300px; border: 1px dashed #ccc; display: flex; align-items: center; justify-content: center; color: #888;">
Không dữ liệu để tạo QR Code
</div>
</div>
<div class="mt-2 is-flex is-justify-content-center is-gap-1">
<a
@click="openLink()"
class="button is-light is-link is-rounded"
:title="isVietnamese ? 'Mở đường dẫn liên kết' : 'Open external link'"
v-if="finalLink"
>
<span class="icon">
<SvgIcon v-bind="{ name: 'open.svg', type: 'primary', size: 24 }" />
</span>
</a>
<a
@click="download()"
class="button is-light is-link is-rounded"
:title="isVietnamese ? 'Tải Xuống QR Code' : 'Download QR Code'"
v-if="finalLink"
>
<span class="icon">
<SvgIcon v-bind="{ name: 'download.svg', type: 'primary', size: 24 }" />
</span>
</a>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useStore } from "@/stores/index";
const store = useStore();
const isVietnamese = computed(() => store.lang === "vi")
const { $getpath, $snackbar } = useNuxtApp()
const props = defineProps({
row: Object,
link: String
})
const finalLink = computed(() => {
if (props.link) {
return props.link
}
if (props.row && props.row.code) {
const path = $getpath()
const baseUrl = path ? path.replace('api.', '') : '';
return `${baseUrl}loan/${props.row.code}`
}
return ''
})
function openLink() {
if (finalLink.value) {
window.open(finalLink.value, "_blank")
}
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function download() {
if (!finalLink.value) return;
let svg = document.getElementById('qrcode');
let attempts = 0;
while (!svg && attempts < 5) {
await sleep(100);
svg = document.getElementById('qrcode');
attempts++;
}
if (!svg) {
console.error("QR Code SVG element not found after waiting.");
$snackbar(isVietnamese.value ? 'Không tìm thấy mã QR để tải xuống.' : 'QR Code not found for download.', { type: 'is-danger' });
return;
}
const serializer = new XMLSerializer()
const svgData = serializer.serializeToString(svg)
const svgBlob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' })
const url = URL.createObjectURL(svgBlob)
const image = new Image()
image.onload = () => {
const canvas = document.createElement('canvas')
canvas.width = 300
canvas.height = 300
const ctx = canvas.getContext('2d')
ctx.drawImage(image, 0, 0, 300, 300)
const pngUrl = canvas.toDataURL('image/png')
const linkElement = document.createElement('a')
linkElement.href = pngUrl
const filename = props.row && props.row.code ? `qrcode-${props.row.code}.png` : 'qrcode.png'
linkElement.download = filename
linkElement.click()
URL.revokeObjectURL(url)
$snackbar(isVietnamese.value ? 'Đã tải xuống mã QR!' : 'QR Code downloaded!', { type: 'is-success' });
}
image.src = url
}
</script>

View File

@@ -0,0 +1,10 @@
<template>
<div>
<DataView v-if="vbind" v-bind="vbind"></DataView>
</div>
</template>
<script>
export default {
props: ['vbind']
}
</script>

View File

@@ -0,0 +1,175 @@
<template>
<div v-if="record">
<div class="columns is-multiline mx-0">
<div :class="`column is-3 ${viewport===1? 'px-0 pb-1' : ''}`">
<div class="field">
<label class="label">{{findLang('code')}}<b class="ml-1 has-text-danger">*</b></label>
<div class="control">
<input class="input has-text-black" disabled type="text" placeholder="" v-model="record.code">
</div>
<p class="help is-danger" v-if="errors.code">{{ errors.code }}</p>
</div>
</div>
<div :class="`column is-6 ${viewport===1? 'px-0 pb-1' : ''}`">
<div class="field">
<label class="label">Tên công ty<b class="ml-1 has-text-danger">*</b></label>
<div class="control">
<input class="input" type="text" placeholder="" v-model="record.fullname">
</div>
<p class="help is-danger" v-if="errors.fullname">{{errors.fullname}}</p>
</div>
</div>
<div :class="`column is-3 ${viewport===1? 'px-0 pb-1' : ''}`">
<div class="field">
<label class="label">{{findLang('shortname')}}</label>
<div class="control">
<input class="input" type="text" placeholder="" v-model="record.shortname">
</div>
<p class="help is-danger" v-if="errors.shortname">{{errors.shortname}}</p>
</div>
</div>
<div :class="`column is-4 ${viewport===1? 'px-0 pb-1' : ''}`">
<div class="field">
<label class="label">{{findLang('taxcode')}}</label>
<div class="control">
<input class="input" type="text" placeholder="" v-model="record.legal_code">
</div>
<p class="help is-danger" v-if="errors.legal_code">{{errors.legal_code}}</p>
</div>
</div>
<div :class="`column is-4 ${viewport===1? 'px-0 pb-1' : ''}`">
<div class="field">
<label class="label">Điện thoại</label>
<div class="control">
<InputPhone v-bind="{record: record, attr: 'phone', placeholder: ''}" @phone="selected('phone', $event)"></InputPhone>
</div>
<p class="help is-danger" v-if="errors.phone">{{ errors.phone }}
<a v-if="existedCustomer" @click="showCustomer()">Chi tiết</a>
</p>
</div>
</div>
<div :class="`column is-4 ${viewport===1? 'px-0 pb-1' : ''}`">
<div class="field">
<label class="label">Email</label>
<div class="control">
<InputEmail v-bind="{record: record, attr: 'email', placeholder: ''}" @email="selected('email', $event)"></InputEmail>
</div>
<p class="help is-danger" v-if="errors.email">{{ errors.email }}</p>
</div>
</div>
<div :class="`column is-4 ${viewport===1? 'px-0 pb-1' : ''}`">
<div class="field">
<label class="label">Website</label>
<div class="control">
<input class="input" type="text" placeholder="" v-model="record.website">
</div>
</div>
</div>
<div :class="`column is-4 ${viewport===1? 'px-0 pb-1' : ''}`">
<div class="field">
<label class="label">{{findLang('country')}}</label>
<div class="control">
<SearchBox v-bind="{vdata: store.country, field:'name', column:['name'], first:true, optionid: record.country, position: 'is-top-left'}"
@option="selected('_country', $event)"></SearchBox>
</div>
</div>
</div>
<div :class="`column is-4 ${viewport===1? 'px-0 pb-1' : ''}`">
<div class="field">
<label class="label">{{findLang('province')}}</label>
<div class="control">
<input class="input" type="text" placeholder="" v-model="record.province">
</div>
</div>
</div>
<div :class="`column is-12 ${viewport===1? 'px-0 pb-1' : ''}`">
<div class="field">
<label class="label">{{findLang('address')}}</label>
<div class="control">
<input class="input" type="text" placeholder="" v-model="record.address">
</div>
<p class="help is-danger" v-if="errors.address"></p>
</div>
</div>
</div>
<div class="mt-2">
<button class="button is-primary has-text-white" @click="update()">{{ findLang('save') }}</button>
</div>
<Modal @close="showmodal=undefined" v-bind="showmodal" v-if="showmodal"></Modal>
</div>
</template>
<script setup>
import InputPhone from '~/components/common/InputPhone'
import InputEmail from '~/components/common/InputEmail'
import SearchBox from '~/components/SearchBox'
import { useStore } from '@/stores/index'
var props = defineProps({
pagename: String,
row: Object
})
const store = useStore()
const { $find, $getdata, $updateapi, $insertapi, $findapi, $getapi, $empty, $errPhone, $resetNull, $snackbar } = useNuxtApp()
const emit = defineEmits(['update', 'dataevent'])
var viewport = store.viewport
var errors = ref({})
var record = ref()
var showmodal = undefined
var existedCustomer = undefined
async function initData() {
if(props.row) {
let conn = $findapi('company')
conn.params.filter = {id: props.row.company || props.row.customer__company || props.row.id}
let rs = await $getapi([conn])
let found = $find(rs, {name: 'company'})
if(found.data.rows.length>0) record.value = found.data.rows[0]
} else {
record.value = {}
}
}
function findLang(code) {
let found = $find(store.common, {code: code})
return found? found[store.lang] : ''
}
function showCustomer() {
showmodal.value = {component: 'customer/CustomerView', width: '60%', height: '600px', title: 'Khách hàng', vbind: {row: existedCustomer}}
}
function selected(attr, obj) {
record.value[attr] = obj
}
function checkError() {
existedCustomer = undefined
errors.value = {}
if($empty(record.value.fullname)) errors.value.fullname = 'Họ tên không được bỏ trống'
if(record.value.phone) {
let text = $errPhone(record.value.phone)
if(text) errors.value.phone = text
}
return Object.keys(errors.value).length>0
}
async function update() {
if(checkError()) return
if(!record.value.id) {
if(record.value.phone) record.value.phone = record.value.phone.trim()
let obj = await $getdata('company', {phone: record.value.phone}, undefined, true)
if(obj) {
existedCustomer = obj
errors.phone = 'Số điện thoại đã tồn tại trong hệ thống.'
}
}
record.value = $resetNull(record.value)
if(record.value._country) record.value.country = record.value._country.id
if(!record.value.creator) record.value.creator = store.login.id
record.value.updater = store.login.id
record.update_time = new Date()
let rs = record.value.id? await $updateapi('company', record.value)
: await $insertapi('company', record.value)
if(rs==='error') return
if(!record.value.id) $snackbar(`Khách hàng đã được khởi tạo với mã <b>${rs.code}</b>`, 'Thành công', 'Success')
record.value.id = rs.id
let ele = await $getdata('company', {id:rs.id}, null, true)
emit('update', ele)
emit('modalevent', {name: 'dataevent', data: ele})
}
initData()
</script>

View File

@@ -0,0 +1,136 @@
<template>
<!-- Nội dung chính - chỉ hiển thị form nhân (màn hình chọn đã được xử SearchBox) -->
<div class="columns mx-0 px-0 py-2">
<div
:class="`column is-narrow p-0 pr-4 ${viewport === 1 ? 'px-0' : ''}`"
:style="`${viewport === 1 ? '' : 'border-right: 1px solid #B0B0B0;'}`"
>
<template v-if="viewport > 1">
<div
v-for="(v, i) in tabs"
:key="i"
:class="['is-clickable p-3', i !== 0 && 'mt-2', getStyle(v)]"
style="width: 130px; border-radius: 4px;"
@click="changeTab(v)"
>
{{ isVietnamese ? v.name : v.en }}
</div>
</template>
<div v-else class="field is-grouped is-grouped-multiline">
<div class="control" v-for="(v, i) in tabs" @click="changeTab(v)">
<div style="width: 130px">
<div :class="`py-3 px-3 ${getStyle(v)}`">
{{ isVietnamese ? v.name : v.en }}
</div>
</div>
</div>
</div>
</div>
<div :class="['column', { 'px-0': viewport === 1 }]">
<CustomerInfo2
v-if="tab === 'info' && record !== undefined"
v-bind="{ row: record, pagename, application }"
@update="update"
@close="emit('close')"
/>
<template v-if="record">
<ImageGallery
v-bind="{
row: record,
pagename: pagename,
show: ['delete'],
api: 'customerfile',
}"
@update="update"
v-if="tab === 'image'"
></ImageGallery>
<CustomerView
v-bind="{ row: record, pagename: pagename }"
@update="update"
@close="emit('close')"
v-if="tab === 'print'"
></CustomerView>
</template>
</div>
</div>
<Modal @close="handleModalClose" v-bind="showmodal" v-if="showmodal"></Modal>
</template>
<script setup>
import { computed, ref } from "vue";
import { useNuxtApp } from "#app";
import CustomerInfo2 from "~/components/customer/CustomerInfo2";
import CustomerView from "~/components/customer/CustomerView";
import Modal from "~/components/Modal.vue";
import { useStore } from "@/stores/index";
const nuxtApp = useNuxtApp();
const { $dialog } = nuxtApp;
const store = useStore();
var props = defineProps({
pagename: String,
row: Object,
application: Object,
isEditMode: Boolean,
handleCustomer: Function,
});
const lang = computed(() => store.lang);
const isVietnamese = computed(() => lang.value === "vi");
const emit = defineEmits(["modalevent", "close"]);
var viewport = 5;
var tabs = [
{ code: "info", name: "1. Thông tin", en: "1. Information", active: true },
{ code: "image", name: "2. Hình ảnh", en: "2. Images", active: false },
{ code: "print", name: "3. Bản in", en: "3. Print", active: false },
];
var tab = ref("info");
var record = props.row || null;
var showmodal = ref();
function getStyle(v) {
let check = record ? record.id : false;
// let check = props.isEditMode;
if (v.tab === "info") check = true;
return v.code === tab.value
? "has-background-primary has-text-white"
: `has-background-light ${check ? "" : "has-text-grey"}`;
}
function changeTab(v) {
if (tab.value === v.code) return;
if (!record)
return $dialog(
"Vui lòng <b>lưu dữ liệu</b> trước khi chuyển sang mục tiếp theo",
"Thông báo"
);
tab.value = v.code;
}
function handleModalClose() {
showmodal.value = undefined;
}
function update(v) {
record = {
...v,
label: `${v.code} / ${v.fullname} / ${v.phone || ""}`,
}
emit("modalevent", { name: "dataevent", data: record });
if (!props.isEditMode) emit("close");
}
</script>
<style scoped>
.title {
font-family: "Segoe UI", "Roboto", "Helvetica Neue", Helvetica, Arial,
sans-serif !important;
}
.button.is-large.is-fullwidth:hover {
color: white !important;
background-color: rgb(75, 114, 243) !important;
.title {
color: white !important;
}
}
</style>

View File

@@ -0,0 +1,783 @@
<template v-if="isLoaded">
<div v-if="record && isLoaded">
<div class="columns is-multiline mx-0">
<div :class="`column is-2 px-0 ${viewport === 1 ? 'px-0 pb-1' : ''}`">
<div class="field">
<label class="label"
>{{ dataLang && findFieldName("custcode")[lang]
}}<b class="ml-1 has-text-danger">*</b></label
>
<div class="control">
<input
class="input has-text-black"
disabled
type="text"
placeholder=""
/>
</div>
<p class="help is-danger" v-if="errors.code">{{ errors.code }}</p>
</div>
</div>
<div :class="`column is-4 ${viewport === 1 ? 'px-0 pb-1' : ''}`">
<div class="field">
<label class="label"
>{{ dataLang && findFieldName("name")[lang]
}}<b class="ml-1 has-text-danger">*</b></label
>
<div class="control">
<input class="input" type="text" placeholder="" />
</div>
<p class="help is-danger" v-if="errors.fullname">
{{ errors.fullname }}
</p>
</div>
</div>
<div :class="`column is-3 px-0 ${viewport === 1 ? 'px-0 pb-1' : ''}`">
<div class="field">
<label class="label"
>{{ dataLang && findFieldName("phone_number")[lang]
}}<b class="ml-1 has-text-danger">*</b></label
>
<div class="control">
<InputPhone
v-bind="{ record: record, attr: 'phone', placeholder: '' }"
@phone="selected('phone', $event)"
>
</InputPhone>
</div>
<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-3 ${viewport === 1 ? 'px-0 pb-1' : ''}`">
<div class="field">
<label class="label">Email</label>
<div class="control">
<InputEmail
v-bind="{ record: record, attr: 'email', placeholder: '' }"
@email="selected('email', $event)"
>
</InputEmail>
</div>
<p class="help is-danger" v-if="errors.email">{{ errors.email }}</p>
</div>
</div>
<div :class="`column is-3 px-0 ${viewport === 1 ? 'px-0 pb-1' : ''}`">
<div class="field">
<label class="label"
>{{ dataLang && findFieldName("gender")[lang]
}}<b class="ml-1 has-text-danger">*</b></label
>
<div class="control">
<SearchBox
v-bind="{
vdata: store.sex,
api: 'sex',
field: isVietnamese ? 'name' : 'en',
column: ['name', 'en'],
first: true,
optionid: record.sex,
}"
@option="selected('sex', $event)"
></SearchBox>
</div>
</div>
</div>
<div :class="`column is-3 ${viewport === 1 ? 'px-0 pb-1' : ''}`">
<div class="field">
<label class="label"
>{{ dataLang && findFieldName("birth_date")[lang]
}}<b class="ml-1 has-text-danger">*</b></label
>
<div class="control">
<Datepicker
v-bind="{ record: record, attr: 'dob', maxdate: new Date() }"
@date="selected('dob', $event)"
>
</Datepicker>
</div>
<p class="help is-danger" v-if="errors.dob">{{ errors.dob }}</p>
</div>
</div>
<div :class="`column is-3 px-0 ${viewport === 1 ? 'px-0 pb-1' : ''}`">
<div class="field">
<label class="label"
>{{ dataLang && findFieldName("country")[lang]
}}<b class="ml-1 has-text-danger">*</b></label
>
<div class="control">
<SearchBox
v-bind="{
vdata: store.country,
api: 'country',
field: isVietnamese ? 'name' : 'en',
column: ['name', 'en'],
first: true,
optionid: record.country,
}"
@option="selected('country', $event)"
></SearchBox>
</div>
</div>
</div>
<div :class="`column is-3 ${viewport === 1 ? 'px-0 pb-1' : ''}`">
<div class="field">
<label class="label"
>{{ dataLang && findFieldName("province")[lang]
}}<b class="ml-1 has-text-danger">*</b></label
>
<div class="control">
<input
class="input"
type="text"
placeholder=""
v-model="record.province"
/>
</div>
</div>
</div>
<div :class="`column is-3 px-0 ${viewport === 1 ? 'px-0 pb-1' : ''}`">
<div class="field">
<label class="label"
>{{ dataLang && findFieldName("district")[lang]
}}<b class="ml-1 has-text-danger">*</b></label
>
<div class="control">
<input
class="input"
type="text"
placeholder=""
v-model="record.district"
/>
</div>
</div>
</div>
<div :class="`column is-4 ${viewport === 1 ? 'px-0 pb-1' : ''}`">
<div class="field">
<label class="label"
>{{ dataLang && findFieldName("address")[lang]
}}<b class="ml-1 has-text-danger">*</b></label
>
<div class="control">
<input
class="input"
type="text"
placeholder=""
v-model="record.address"
/>
</div>
<p class="help is-danger" v-if="errors.address"></p>
</div>
</div>
<div :class="`column is-5 px-0 ${viewport === 1 ? 'px-0 pb-1' : ''}`">
<div class="field">
<label class="label">{{
dataLang && findFieldName("company")[lang]
}}</label>
<div class="control">
<SearchBox
v-bind="{
api: 'company',
field: 'fullname',
column: ['fullname'],
first: true,
optionid: record.company,
addon: companyAddon,
viewaddon: companyviewAddon,
}"
@option="selected('company', $event)"
></SearchBox>
</div>
</div>
</div>
<div :class="`column is-3 px-0 ${viewport === 1 ? 'px-0 pb-1' : ''}`">
<div class="field">
<label class="label"
>{{ dataLang && findFieldName("personal_id")[lang]
}}<b class="ml-1 has-text-danger">*</b></label
>
<div class="control">
<SearchBox
v-bind="{
vdata: store.legaltype,
api: 'legaltype',
field: isVietnamese ? 'name' : 'en',
column: ['name', 'en'],
first: true,
optionid: record.legal_type,
}"
@option="selected('legal_type', $event)"
></SearchBox>
</div>
</div>
</div>
<div :class="`column is-3 ${viewport === 1 ? 'px-0 pb-1' : ''}`">
<div class="field">
<label class="label"
>{{ dataLang && findFieldName("idnum")[lang] }}
<b class="ml-1 has-text-danger">*</b></label
>
<div class="control">
<input
class="input"
type="text"
placeholder=""
v-model="record.legal_code"
/>
</div>
<p class="help is-danger" v-if="errors.legal_code">
{{ errors.legal_code }}
</p>
</div>
</div>
<div :class="`column is-2 px-0 ${viewport === 1 ? 'px-0 pb-1' : ''}`">
<div class="field">
<label class="label"
>{{ dataLang && findFieldName("issued_date")[lang] }}
<b class="ml-1 has-text-danger">*</b></label
>
<div class="control">
<Datepicker
v-bind="{
record: record,
attr: 'issued_date',
maxdate: new Date(),
position: 'is-top-left',
}"
@date="selected('issued_date', $event)"
></Datepicker>
</div>
<p class="help is-danger" v-if="errors.issued_date">
{{ errors.issued_date }}
</p>
</div>
</div>
<div :class="`column is-4 ${viewport === 1 ? 'px-0 pb-1' : ''}`">
<div class="field">
<label class="label"
>{{ dataLang && findFieldName("issued_place")[lang] }}
<b class="ml-1 has-text-danger">*</b></label
>
<div class="control">
<input
class="input"
type="text"
placeholder=""
v-model="record.issued_place"
/>
</div>
<p class="help is-danger" v-if="errors.issued_place"></p>
</div>
</div>
<div :class="`column is-3 px-0 ${viewport === 1 ? 'px-0 pb-1' : ''}`">
<div class="field">
<label class="label">Zalo</label>
<div class="control">
<input
class="input"
type="text"
placeholder=""
v-model="record.zalo"
/>
</div>
</div>
</div>
<div :class="`column is-3 ${viewport === 1 ? 'px-0 pb-1' : ''}`">
<div class="field">
<label class="label">Facebook</label>
<div class="control">
<input
class="input"
type="text"
placeholder=""
v-model="record.facebook"
/>
</div>
</div>
</div>
<div :class="`column px-0 is-6 ${viewport === 1 ? 'px-0 pb-1' : ''}`">
<div class="field">
<label class="label">{{
dataLang && findFieldName("note")[lang]
}}</label>
<div class="control">
<textarea
class="textarea"
:placeholder="isVietnamese ? 'Nhập ghi chú...' : 'Enter note...'"
v-model="record.note"
rows="1"
></textarea>
</div>
<p class="help is-danger" v-if="errors.note">{{ errors.note }}</p>
</div>
</div>
</div>
</div>
<div class="mb-4 mt-5">
<Caption
v-bind="{
title: findFieldName('related_person')[lang],
type: 'has-text-warning',
}"
></Caption>
</div>
<div class="columns mb-0 mx-0" v-for="(v, i) in people">
<div :class="`column is-7 ${viewport === 1 ? 'px-0 pb-1' : ''}`">
<label class="label" v-if="i === 0"
>{{ findFieldName("select")[lang]
}}<b class="ml-1 has-text-danger">*</b></label
>
<SearchBox
v-bind="{
position: 'top',
api: 'people',
field: 'label',
column: ['code', 'fullname', 'phone'],
first: true,
optionid: v.people,
addon: peopleAddon,
viewaddon: peopleviewAddon,
position: 'is-top-left',
}"
@option="selectPeople($event, v, i)"
>
</SearchBox>
<p class="help is-danger" v-if="v.error">{{ v.error }}</p>
</div>
<div :class="`column is-3 ${viewport === 1 ? 'px-0 pb-1' : ''}`">
<label class="label" v-if="i === 0"
>{{ findFieldName("relationship")[lang]
}}<b class="ml-1 has-text-danger">*</b></label
>
<SearchBox
v-bind="{
vdata: store.relation,
position: 'top',
api: 'relation',
field: store.lang === 'en' ? 'en' : 'name',
column: ['code', 'name', 'en'],
first: true,
optionid: v.relation,
position: 'is-top-left',
}"
@option="selectRelation($event, v, i)"
>
</SearchBox>
</div>
<div :class="`column ${viewport === 1 ? 'px-0 pb-1' : ''}`">
<label class="label" v-if="i === 0"
>{{ findFieldName("addmore")[lang] }}...</label
>
<button class="button px-2 is-dark mr-3" @click="add()">
<SvgIcon
v-bind="{ name: 'add1.png', type: 'white', size: 20 }"
></SvgIcon>
</button>
<button class="button px-2 is-warning" @click="remove(v, i)">
<SvgIcon
v-bind="{ name: 'bin1.svg', type: 'white', size: 20 }"
></SvgIcon>
</button>
</div>
</div>
<div class="mt-2">
<button class="button is-primary has-text-white" @click="update()">
{{ store.lang === "en" ? "Save" : "Lưu lại" }}
</button>
</div>
<Modal
@close="showmodal = undefined"
v-bind="showmodal"
v-if="showmodal"
></Modal>
</template>
<script setup>
import { ref, computed, onMounted, defineEmits } 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";
const emit = defineEmits(["close", "update"]);
const nuxtApp = useNuxtApp();
const {
$getdata,
$updateapi,
$insertapi,
$empty,
$errPhone,
$resetNull,
$snackbar,
$copy,
$dayjs,
} = nuxtApp;
var props = defineProps({
pagename: String,
row: Object,
application: Object,
});
console.log("props", props);
const store = useStore();
const lang = computed(() => store.lang);
const isVietnamese = computed(() => lang.value === "vi");
const dataLang = ref(store.common);
var viewport = store.viewport;
var errors = ref({});
var record = ref({});
const isLoaded = ref(false);
const dataCustomer = ref(props.row);
if (!props.row && props.application) {
//create customer by copying information form application
let copy = $copy(props.application);
let image = await $getdata("applicationfile", {
ref: copy.id,
file__doc_type__code__in: ["cccd-mt", "cccd-ms", "avatar"],
});
copy.image = image.map((v) => v.file);
delete copy.id;
dataCustomer.value = copy;
}
// const formData = ref({
// // fullname: dataCustomer.value.fullname || "",
// phone: dataCustomer.value.phone || "",
// email: dataCustomer.value.email || "",
// dob: dataCustomer.value.dob || null,
// country: dataCustomer.value.country || 1,
// province: dataCustomer.value.province || null,
// district: dataCustomer.value.district || "",
// address: dataCustomer.value.address || "",
// sex: dataCustomer.value.sex || 1,
// note: dataCustomer.value.note || "",
// legal_type: dataCustomer.value.legal_type || null,
// legal_code: dataCustomer.value.legal_code || "",
// issued_date: dataCustomer.value.issue_date || null,
// issued_place: dataCustomer.value.issue_place || "",
// branch: dataCustomer.value.branch || null,
// user: dataCustomer.value.user || null,
// collaborator: dataCustomer.value.collaborator || null,
// updater: dataCustomer.value.updater || store.login.id,
// creator: store.login.id,
// image: dataCustomer.value.image || null,
// });
var showmodal = ref();
var existedCustomer = undefined;
var people = ref([]);
var peopleAddon = {
component: "people/People",
with: "65%",
height: "600px",
title: store.lang === "en" ? "Related persion" : "Người liên quan",
};
var peopleviewAddon = {
component: "people/People",
width: "65%",
height: "600px",
title: store.lang === "en" ? "Related person" : "Người liên quan",
};
var companyAddon = {
component: "customer/Company",
with: "55%",
height: "400px",
title: store.lang === "en" ? "Company" : "Công ty",
};
var companyviewAddon = {
component: "customer/Company",
width: "55%",
height: "400px",
title: store.lang === "en" ? "Company" : "Công ty",
};
async function initData() {
try {
if (dataCustomer.value.customer) {
record.value = { ...dataCustomer.value.customer, ...formData.value };
people.value = [{}];
} else if (dataCustomer.value.id) {
record.value = props.row;
let rows = await $getdata("customerpeople", {
customer: dataCustomer.value.id,
});
people.value = rows.length > 0 ? rows : [{}];
} else {
const customerCode = await $getdata("getcodeCustomer");
record.value = { ...formData.value, code: customerCode };
isLoaded.value = true;
people.value = [{}];
}
} catch (error) {
console.log("Error in initData:", error);
}
}
function findFieldName(code) {
const field = dataLang.value.find((item) => item.code === code);
return field || { vi: "Không tìm thấy trường", en: "Field not found" };
}
function showCustomer() {
showmodal.value = {
component: "customer/CustomerView",
width: "60%",
height: "600px",
title: store.lang === "en" ? "Customer" : "Khách hàng",
vbind: { row: existedCustomer },
};
}
const selected = (fieldName, value) => {
const finalValue =
value !== null && typeof value === "object"
? value.id || value.index
: value;
record.value[fieldName] = finalValue;
if (errors.value[fieldName]) {
delete errors.value[fieldName];
}
};
function checkError() {
errors.value = {};
if ($empty(record.value.fullname)) {
errors.value.fullname = "Họ tên không được bỏ trống";
}
if ($empty(record.value.phone)) {
errors.value.phone = "Số điện thoại không được bỏ trống";
} else {
let text = $errPhone(record.value.phone);
if (text) errors.value.phone = text;
}
if ($empty(record.value.dob)) {
errors.value.dob = isVietnamese.value
? "Ngày sinh không được bỏ trống"
: "Date of birth cannot be empty";
} else {
if (record.value.dob > new Date()) {
errors.value.dob = isVietnamese.value
? "Ngày sinh không được lớn hơn ngày hiện tại"
: "Date of birth cannot be greater than the current date";
}
if ($dayjs(new Date()).diff(new Date(record.value.dob), "year") < 18) {
errors.value.dob = isVietnamese.value
? "Khách hàng phải đủ 18 tuổi trở lên"
: "Customer must be at least 18 years old";
}
}
if ($empty(record.value.legal_code)) {
errors.value.legal_code = isVietnamese.value
? "Mã số không được bỏ trống"
: "Legal code cannot be empty";
}
if ($empty(record.value.issued_date)) {
errors.value.issued_date = isVietnamese.value
? "Ngày cấp không được bỏ trống"
: "Date of issue cannot be empty";
}
if ($empty(record.value.issued_place)) {
errors.value.issued_place = isVietnamese.value
? "Nơi cấp không được bỏ trống"
: "Place of issue cannot be empty";
}
return Object.keys(errors.value).length > 0;
}
function selectPeople(option, v, i) {
let copy = $copy(v);
copy.people = option.id;
people.value[i] = copy;
people.value = $copy(people.value);
}
function selectRelation(option, v, i) {
let copy = $copy(v);
copy.relation = option ? option.id : null;
people.value[i] = copy;
people.value = $copy(people.value);
}
function add() {
people.value.push({});
}
async function remove(v, i) {
if (v.id) await $deleteapi("customerpeople", v.id);
people.value.splice(i, 1);
if (people.value.length === 0) {
setTimeout(() => (people.value = [{}]));
}
}
async function update() {
try {
if (checkError()) return;
var isNewCustomer =
!dataCustomer.value?.customer ||
dataCustomer.value.customer === null ||
dataCustomer.value.customer === undefined ||
!record.value.id;
if (record.value.id) isNewCustomer = false;
if (isNewCustomer) {
try {
if (record.value.phone) {
record.value.phone = record.value.phone.trim();
let phoneCheck = await $getdata(
"customer",
{ phone: record.value.phone },
undefined,
true
);
if (phoneCheck) {
existedCustomer = phoneCheck;
errors.value.phone = isVietnamese.value
? "Số điện thoại đã tồn tại."
: "Phone number already exists.";
return;
}
}
// Kiểm tra email
if (record.value.email && record.value.email.trim() !== "") {
let emailCheck = await $getdata(
"customer",
{ email: record.value.email.trim() },
undefined,
true
);
if (emailCheck) {
errors.value.email = isVietnamese.value
? "Email đã tồn tại."
: "Email already exists.";
return;
}
}
// Kiểm tra legal_code
if (record.value.legal_code) {
let legalCheck = await $getdata(
"customer",
{ legal_code: record.value.legal_code },
undefined,
true
);
if (legalCheck) {
errors.value.legal_code = isVietnamese.value
? "Số CMND/CCCD đã tồn tại."
: "ID card number already exists.";
return;
}
}
} catch (error) {
console.log("Error checking duplicates:", error);
}
}
let dataToSend = $resetNull({ ...record.value });
if (isNewCustomer) {
delete dataToSend.id;
}
if (!dataToSend.note || dataToSend.note.trim() === "") {
dataToSend.note = null;
}
if (!dataToSend.creator) dataToSend.creator = store.login.id;
dataToSend.updater = store.login.id;
dataToSend.update_time = new Date();
let rs;
if (isNewCustomer) {
rs = await $insertapi("customer", dataToSend, undefined, false);
} else {
rs = await $updateapi("customer", dataToSend);
}
if (rs === "error") {
$snackbar("Có lỗi xảy ra khi lưu dữ liệu", "Lỗi", "Error");
return;
}
record.value.id = rs.id;
const customerData = await $getdata(
"customer",
{ id: rs.id },
undefined,
true
);
if (isNewCustomer) {
// link user=customer
await $getdata("linkusercustomer", undefined, { phone: rs.phone });
$snackbar(
`${
isVietnamese.value
? "Khách hàng đã được khởi tạo với mã"
: "Customer has been created with code"
} <b>${rs.code}</b>`,
"Thành công",
"Success"
);
} else {
$snackbar(
`${
isVietnamese.value
? "Khách hàng đã được cập nhật với mã"
: "Customer has been updated with code"
} <b>${rs.code}</b>`,
"Thành công",
"Success"
);
}
record.value = { ...record.value, ...customerData };
emit("update", customerData);
//Xử people relationships nếu
if (people.value && people.value.length > 0) {
let filter = people.value.filter((v) => v.people);
if (filter.length > 0) {
filter.map((v) => (v.customer = rs.id));
await $insertapi("customerpeople", filter);
}
}
//Xử lý file uploads nếu có
if (record.value.image && record.value.image.length > 0) {
let arr = [];
record.value.image.map((v) =>
arr.push({ ref: record.value.id, file: v })
);
await $insertapi("customerfile", arr, undefined, false);
}
emit("update", customerData);
emit("close");
} catch (error) {
console.error("Error in update:", error);
}
}
onMounted(async () => {
try {
await initData();
isLoaded.value = true;
} catch (error) {
console.error("Error loading data:", error);
}
});
</script>

View File

@@ -0,0 +1,476 @@
<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", { phone: record.value.phone.trim() }, undefined, true);
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", { email: record.value.email.trim() }, undefined, true);
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", { legal_code: record.value.legal_code }, undefined, true);
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", { id: customerId }, undefined, true);
$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", { customer: props.row.id }, undefined, true);
individualData.value = ind || { dob: null, sex: 1 };
const rows = await $getdata("customerpeople", { customer: props.row.id });
people.value = rows.length > 0 ? rows : [{}];
} else {
const org = await $getdata("organization", { customer: props.row.id }, undefined, true);
organizationData.value = org || { established_date: null };
if (org && org.id) {
const rows = await $getdata("legalrep", { 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,69 @@
<template>
<div 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
class="button is-large is-fullwidth"
style="height: 100px"
@click="selectType('individual')"
>
<div class="has-text-centered">
<div class="">
<SvgIcon v-bind="{ name: 'user.svg', type: 'black', size: 40 }"></SvgIcon>
</div>
<div class="title is-6 mb-0">
{{ isVietnamese ? "Cá nhân" : "Individual" }}
</div>
</div>
</button>
</div>
<div class="column is-6">
<button
class="button is-large is-fullwidth"
style="height: 100px"
@click="selectType('company')"
>
<div class="has-text-centered">
<div class="">
<SvgIcon
v-bind="{ name: 'building.svg', type: 'black', size: 40 }"
></SvgIcon>
</div>
<div class="title is-6 mb-0">
{{ isVietnamese ? "Doanh nghiệp" : "Company" }}
</div>
</div>
</button>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from "vue";
import { useStore } from "@/stores/index";
const store = useStore();
const lang = computed(() => store.lang);
const isVietnamese = computed(() => lang.value === "vi");
const emit = defineEmits(["select", "modalevent"]);
const props = defineProps({
application: Object,
});
function selectType(type) {
// Emit event qua modalevent để Modal có thể forward
emit("modalevent", { name: "select", data: type });
}
</script>
<style scoped>
.button.is-large.is-fullwidth:hover {
background-color: #3292ec !important;
}
</style>

View File

@@ -0,0 +1,301 @@
<template>
<div :id="docid" v-if="record">
<div>
<Caption v-bind="{ title: this.data && findFieldName('info')[this.lang] }"></Caption>
<div class="columns is-multiline mx-0">
<div class="column is-3 pb-1 px-0">
<div class="field">
<label class="label"> khách hàng</label>
<div class="control">
<span class="hyperlink" @click="$copyToClipboard(record.code)">{{ record.code }}</span>
</div>
</div>
</div>
<div class="column is-3 pb-1 px-0">
<div class="field">
<label class="label">Tên khách hàng</label>
<div class="control">
{{ record.fullname }}
</div>
</div>
</div>
<div class="column is-3 pb-1 px-0">
<div class="field">
<label class="label">Điện thoại</label>
<div class="control">
<span class="hyperlink" @click="openPhone()">{{ record.phone }}</span>
</div>
</div>
</div>
<div class="column is-3 pb-1 px-0">
<div class="field">
<label class="label">Email</label>
<div class="control" style="word-break: break-all">
{{ record.email || "/" }}
</div>
</div>
</div>
<div class="column is-3 pb-1 px-0">
<div class="field">
<label class="label">Giấy tờ</label>
<div class="control">
{{ record.legal_type__name || "/" }}
</div>
</div>
</div>
<div class="column is-3 pb-1 px-0">
<div class="field">
<label class="label"> số giấy tờ</label>
<div class="control">
{{ record.legal_code || "/" }}
</div>
</div>
</div>
<div class="column is-3 pb-1 px-0">
<div class="field">
<label class="label">Ngày cấp</label>
<div class="control">
{{ record.issued_date ? $dayjs(record.issued_date).format("DD/MM/YYYY") : "/" }}
</div>
</div>
</div>
<div class="column is-3 pb-1 px-0">
<div class="field">
<label class="label">Nơi cấp</label>
<div class="control">
{{ record.issued_place__name || "/" }}
</div>
</div>
</div>
<div class="column is-3 pb-1 px-0">
<div class="field">
<label class="label">Loại khách hàng</label>
<div class="control">
{{ record.type__name || "/" }}
</div>
</div>
</div>
<div class="column is-3 pb-1 px-0">
<div class="field">
<label class="label">Quốc gia</label>
<div class="control">
{{ isVietnamese ? record.country__name : record.country__en || "/" }}
</div>
</div>
</div>
<div class="column is-3 pb-1 px-0">
<div class="field">
<label class="label">Địa chỉ liên hệ</label>
<div class="control">
{{ record.contact_address || "/" }}
</div>
</div>
</div>
<div class="column is-3 pb-1 px-0">
<div class="field">
<label class="label">Địa chỉ thường trú</label>
<div class="control">
{{ record.address || "/" }}
</div>
</div>
</div>
<div class="column is-3 pb-1 px-0">
<div class="field">
<label class="label">Người tạo</label>
<div class="control">
<span class="hyperlink" @click="openUser(record.creator)">{{ record.creator__fullname || "/" }}</span>
</div>
</div>
</div>
<div class="column is-3 pb-1 px-0">
<div class="field">
<label class="label">Thời gian tạo</label>
<div class="control">
<span
>{{ $dayjs(record.create_time).format("DD/MM/YYYY") }}
{{ $dayjs(record.create_time).format("HH:mm") }}</span
>
</div>
</div>
</div>
<div class="column is-3 pb-1 px-0">
<div class="field">
<label class="label">Người cập nhật</label>
<div class="control">
<span class="hyperlink" @click="openUser(record.updater)">{{ record.updater__fullname || "/" }}</span>
</div>
</div>
</div>
<div class="column is-3 pb-1 px-0">
<div class="field">
<label class="label">Thời gian cập nhật</label>
<div class="control">
<span>
{{ record.update_time ? $dayjs(record.update_time).format("DD/MM/YYYY HH:mm") : '/' }}
</span>
</div>
</div>
</div>
</div>
</div>
<div class="mt-3">
<Caption v-bind="{ title: isVietnamese ? 'Hình ảnh' : 'Images' }"></Caption>
<ImageGallery v-bind="{ row: record, api: 'customerfile', hideopt: true }"></ImageGallery>
</div>
<div class="mt-3">
<Caption v-bind="{ title: this.isIndividual ? 'Người liên quan' : 'Người đại diện pháp luật' }"></Caption>
<div class="mt-2">
<div
v-if="this.relatedPeople && this.relatedPeople.length > 0"
v-for="relatedPerson in this.relatedPeople"
class="columns is-0 mb-2"
>
<span class="column is-2">{{ relatedPerson.people__code }}</span>
<span class="column is-4 ">
<span class="has-text-primary hyperlink"
@click="openRelatedPerson(relatedPerson.people)"
>{{ relatedPerson.people__fullname }}</span>
<span> ({{ relatedPerson.relation__name }})</span>
</span>
<span class="column is-4">{{ relatedPerson.people__phone }}</span>
</div>
<div v-else class="has-text-grey">
Chưa {{ this.isIndividual ? 'người liên quan' : 'người đại diện pháp luật' }}
</div>
</div>
</div>
<div v-if="record.count_product >0" class="mt-3">
<Caption class="mb-2" v-bind="{ title: this.data && findFieldName('transaction')[this.lang] }"></Caption>
<DataView v-bind="{
setting: 'customer-all-transaction',
pagename: this.$id(),
api: 'customer',
params: {
filter: { id: this.row.customer || this.row.id },
/* copied from 02-connection.js */
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',
distinct_values: {
label: { type: 'Concat', field: ['code', 'fullname', 'phone', 'legal_code'] },
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',
},
}" />
</div>
<div class="mt-4 border-bottom" id="ignore"></div>
<div class="buttons mt-2 is-flex is-gap-1" id="ignore">
<button v-if="$getEditRights('edit', { code: 'customer', category: 'topmenu' })" class="button is-primary" @click="edit()">Chỉnh sửa</button>
<button class="button is-light" @click="$exportpdf(docid, record.code)">In thông tin</button>
</div>
<Modal @close="showmodal = undefined" v-bind="showmodal" @dataevent="changeInfo" v-if="showmodal"></Modal>
</div>
</template>
<script>
import { useStore } from "@/stores/index";
export default {
setup() {
const store = useStore();
return { store };
},
props: ["row", "pagename"],
data() {
return {
record: undefined,
relatedPeople: undefined,
errors: {},
showmodal: undefined,
docid: this.$id(),
data: this.store.common,
isEditMode: this.isEditMode,
};
},
computed: {
lang() {
return this.store.lang;
},
isVietnamese() {
return this.store.lang === "vi";
},
isIndividual() {
return this.record.type === 1;
}
},
async created() {
this.record = await this.$getdata("customer", { id: this.row.customer || this.row.id }, undefined, true);
if (this.isIndividual) {
this.relatedPeople = await this.$getdata("customerpeople", { customer: this.row.customer || this.row.id });
} else {
const org = await this.$getdata('organization', { customer: this.row.customer || this.row.id }, undefined, true);
this.relatedPeople = await this.$getdata("legalrep", { organization: org.id });
}
},
methods: {
findFieldName(code) {
let field = this.data.find((v) => v.code === code);
return field;
},
copy(value) {
this.$copyToClipboard(value);
this.$snackbar("Đã copy vào clipboard.", "Copy", "Success");
},
openPhone() {
this.showmodal = {
title: "Điện thoại",
height: "180px",
width: "400px",
component: "common/Phone",
vbind: { row: this.row, pagename: this.pagename },
};
},
selected(attr, obj) {
this.record[attr] = obj;
},
edit() {
this.showmodal = {
component: "customer/Customer",
width: "80%",
height: "600px",
title: this.store.lang === "en" ? "Edit Customer" : "Chỉnh sửa khách hàng",
vbind: { row: this.record, isEditMode: true },
};
},
async changeInfo(v) {
this.record = this.$copy(v);
// refetch relatedPeople
if (this.isIndividual) {
this.relatedPeople = await this.$getdata("customerpeople", { customer: this.row.customer || this.row.id });
} else {
const org = await this.$getdata('organization', { customer: this.row.customer || this.row.id }, undefined, true);
this.relatedPeople = await this.$getdata("legalrep", { organization: org.id });
}
},
openUser(userId) {
if (!userId) return;
this.showmodal = {
component: "user/UserInfo",
width: "50%",
height: "200px",
title: "User",
vbind: { userId: userId },
};
},
async openRelatedPerson(peopleId) {
const peopleRow = await this.$getdata('people', { id: peopleId }, undefined, true);
this.showmodal = {
component: "people/PeopleView",
vbind: { row: peopleRow },
title: 'Người liên quan',
width: '65%',
height: '400px',
}
}
},
};
</script>

View File

@@ -0,0 +1,540 @@
<template>
<span class="tooltip">
<a
class="mr-4"
@click="checkFilter() ? false : $emit('modalevent', { name: 'dosort', data: 'az' })"
>
<SvgIcon
v-bind="{ name: 'az.svg', type: checkFilter() ? 'grey' : 'primary', size: 22 }"
></SvgIcon>
</a>
<span
class="tooltiptext"
style="top: 100%; bottom: unset; min-width: max-content; left: 25px"
v-html="'Sắp xếp tăng dần'"
></span>
</span>
<span class="tooltip">
<a
class="mr-4"
@click="checkFilter() ? false : $emit('modalevent', { name: 'dosort', data: 'za' })"
>
<SvgIcon
v-bind="{ name: 'az.svg', type: checkFilter() ? 'grey' : 'primary', size: 22 }"
></SvgIcon>
</a>
<span
class="tooltiptext"
style="top: 100%; bottom: unset; min-width: max-content; left: 25px"
>Sắp xếp giảm dần</span
>
</span>
<span class="tooltip">
<a class="mr-4" @click="moveLeft()">
<SvgIcon v-bind="{ name: 'left5.png', type: 'primary', size: 22 }"></SvgIcon>
</a>
<span
class="tooltiptext"
style="top: 100%; bottom: unset; min-width: max-content; left: 25px"
>Chuyển cột sang trái</span
>
</span>
<span class="tooltip">
<a class="mr-4" @click="moveRight()">
<SvgIcon v-bind="{ name: 'right5.png', type: 'primary', size: 22 }"></SvgIcon>
</a>
<span
class="tooltiptext"
style="top: 100%; bottom: unset; min-width: max-content; left: 25px"
>Chuyển cột sang phải</span
>
</span>
<span class="tooltip">
<a class="mr-4" @click="resizeWidth()">
<SvgIcon v-bind="{ name: 'thick.svg', type: 'primary', size: 22 }"></SvgIcon>
</a>
<span
class="tooltiptext"
style="top: 100%; bottom: unset; min-width: max-content; left: 25px"
>Tăng độ rộng cột</span
>
</span>
<span class="tooltip">
<a class="mr-4" @click="resizeWidth(true)">
<SvgIcon v-bind="{ name: 'thin.svg', type: 'primary', size: 23 }"></SvgIcon>
</a>
<span
class="tooltiptext"
style="top: 100%; bottom: unset; min-width: max-content; left: 25px"
>Giảm độ rộng cột</span
>
</span>
<span class="tooltip">
<a class="mr-4" @click="hideField()">
<SvgIcon v-bind="{ name: 'eye-off.svg', type: 'primary', size: 23 }"></SvgIcon>
</a>
<span
class="tooltiptext"
style="top: 100%; bottom: unset; min-width: max-content; left: 25px"
>Ẩn cột</span
>
</span>
<!-- <template v-if="store.login ? store.login.is_admin : false"> -->
<span class="tooltip">
<a class="mr-4" @click="currentField.mandatory ? false : doRemove()">
<SvgIcon v-bind="{ name: 'bin.svg', type: 'primary', size: 23 }"></SvgIcon>
</a>
<span
class="tooltiptext"
style="top: 100%; bottom: unset; min-width: max-content; left: 25px"
>Xóa cột</span
>
</span>
<span class="tooltip">
<a
class="mr-4"
:class="currentField.format === 'number' ? null : 'has-text-grey-light'"
@click="
currentField.format === 'number'
? $emit('modalevent', { name: 'copyfield', data: currentField })
: false
"
>
<SvgIcon v-bind="{ name: 'copy.svg', type: 'primary', size: 22 }"></SvgIcon>
</a>
<span
class="tooltiptext"
style="top: 100%; bottom: unset; min-width: max-content; left: 25px"
>Sao chép cột</span
>
</span>
<span class="tooltip">
<a class="mr-4" @click="fieldList()">
<SvgIcon v-bind="{ name: 'menu4.png', type: 'primary', size: 22 }"></SvgIcon>
</a>
<span
class="tooltiptext"
style="top: 100%; bottom: unset; min-width: max-content; left: 25px"
>Danh sách cột</span
>
</span>
<span class="tooltip">
<a class="mr-4" @click="createField()">
<SvgIcon v-bind="{ name: 'add.png', type: 'primary', size: 22 }"></SvgIcon>
</a>
<span
class="tooltiptext"
style="top: 100%; bottom: unset; min-width: max-content; left: 25px"
>Tạo cột mới</span
>
</span>
<span class="tooltip">
<a class="mr-4" @click="tableOption()">
<SvgIcon v-bind="{ name: 'more.svg', type: 'primary', size: 22 }"></SvgIcon>
</a>
<span
class="tooltiptext"
style="top: 100%; bottom: unset; min-width: max-content; left: 25px"
>Tùy chọn bảng</span
>
</span>
<span class="tooltip">
<a class="mr-4" @click="saveSetting()">
<SvgIcon v-bind="{ name: 'save.svg', type: 'primary', size: 22 }"></SvgIcon>
</a>
<span
class="tooltiptext"
style="top: 100%; bottom: unset; min-width: max-content; left: 25px"
>Lưu thiết lập</span
>
</span>
<!-- </template> -->
<div class="panel-tabs mb-2">
<a
v-for="(v, i) in getMenu().filter((x) =>
currentField.format === 'number'
? currentField.formula
? true
: x.code !== 'formula'
: !['filter', 'formula'].find((y) => y === x.code)
)"
:key="i"
:class="selectTab.code === v.code ? 'is-active' : 'has-text-primary'"
@click="changeTab(v)"
>
{{ v.name }}
</a>
</div>
<div v-if="currentTab === 'detail'">
<p class="fs-14 mt-3">
<strong> Tên trường: </strong> {{ currentField.name }}
<a @click="copyContent(currentField.name)">
<span class="tooltip">
<SvgIcon
class="ml-1"
v-bind="{ name: 'copy.svg', type: 'primary', size: 16 }"
></SvgIcon>
<span
class="tooltiptext"
style="top: 100%; bottom: unset; min-width: max-content; left: 25px"
>Copy</span
>
</span>
</a>
</p>
<label class="label fs-14 mt-3">Mô tả<span class="has-text-danger"> *</span></label>
<div class="field has-addons">
<div class="control is-expanded">
<input
class="input fs-14"
type="text"
@change="changeLabel($event.target.value)"
v-model="label"
/>
</div>
<div class="control">
<button class="button" @click="editLabel()">
<SvgIcon v-bind="{ name: 'pen.svg', type: 'dark', size: 19 }"></SvgIcon>
</button>
</div>
</div>
<p class="help is-danger" v-if="errors.find((v) => v.name === 'label')">
{{ errors.find((v) => v.name === "label").msg }}
</p>
<div class="field mt-3">
<label class="label fs-14"
>Kiểu dữ liệu<span class="has-text-danger"> * </span></label
>
<div class="control fs-14">
<span class="mr-4" v-for="(v, i) in datatype">
<span class="icon-text" v-if="radioType === v">
<SvgIcon
v-bind="{ name: 'radio-checked.svg', type: 'gray', size: 22 }"
></SvgIcon>
</span>
{{ v.name }}
</span>
</div>
</div>
<div class="field is-horizontal" v-if="currentField.format === 'number'">
<div class="field-body">
<div class="field">
<label class="label fs-14"
>Đơn vị <span class="has-text-danger"> * </span>
</label>
<div class="control">
<SearchBox
v-bind="{
vdata: moneyunit,
field: 'name',
column: ['name'],
first: true,
position: 'is-top-left',
}"
@option="selected('_account', $event)"
></SearchBox>
</div>
<p class="help has-text-danger" v-if="errors.find((v) => v.name === 'unit')">
{{ errors.find((v) => v.name === "unit").msg }}
</p>
</div>
<div class="field is-narrow">
<label class="label fs-14">Phần thập phân</label>
<div class="control">
<input
class="input"
type="text"
placeholder=""
v-model="decimal"
@input="changeDecimal($event.target.value)"
/>
</div>
</div>
</div>
</div>
<div class="field is-horizontal mt-3">
<div class="field-body">
<div class="field">
<label class="label fs-14">Định dạng nâng cao</label>
<p class="control fs-14">
<span
class="mr-4"
v-for="(v, i) in colorchoice.filter((v) => v.code !== 'condition')"
>
<a class="icon-text" @click="changeTemplate(v)">
<SvgIcon
v-bind="{
name: `radio-${radioTemplate === v.code ? '' : 'un'}checked.svg`,
type: 'gray',
size: 22,
}"
>
</SvgIcon>
</a>
{{ v.name }}
</span>
</p>
</div>
</div>
</div>
<p class="mt-3" v-if="radioTemplate === 'option'">
<button class="button is-primary is-small has-text-white" @click="showSidebar()">
<span class="fs-14">{{
`${currentField.template ? "Sửa" : "Tạo"} định dạng`
}}</span>
</button>
</p>
</div>
<div v-else-if="currentTab === 'value'">
<ScrollBox
v-bind="{
data: props.filterData,
name: props.field.name,
maxheight: '380px',
perpage: 20,
}"
@selected="doSelect"
/>
</div>
<Modal
v-bind="showmodal"
v-if="showmodal"
@label="changeLabel"
@updatefields="updateFields"
@close="close"
></Modal>
</template>
<script setup>
import { useStore } from "@/stores/index";
import ScrollBox from "~/components/datatable/ScrollBox";
const store = useStore();
const {
$copy,
$stripHtml,
$clone,
$arrayMove,
$snackbar,
$copyToClipboard,
} = useNuxtApp();
var props = defineProps({
pagename: String,
field: Object,
filters: Object,
filterData: Object,
width: String,
});
const emit = defineEmits(["modalevent", "changepos", "close"]);
var colorchoice = store.colorchoice;
var errors = [];
var currentTab = ref("value");
var currentField = $copy(props.field);
var pagedata = store[props.pagename];
var fields = [];
var label = currentField.label;
var showmodal = ref();
const checkFilter = function () {};
const getMenu = function () {
let field = currentField;
field.disable = "display,tooltip";
let arr = field.disable ? field.disable.split(",") : undefined;
let array = arr
? store.menuchoice.filter((v) => arr.findIndex((x) => x === v.code) < 0)
: store.menuchoice;
//if (store.login ? !(store.login.is_admin === false) : true) array = [array[0]];
return array;
};
var selectTab = getMenu()[0];
var datatype = store.datatype;
var current = 1;
var value1 = undefined;
var value2 = undefined;
var moneyunit = store.moneyunit;
var radioType = store.datatype.find((v) => v.code === currentField.format);
var selectUnit =
currentField.format === "number"
? moneyunit.find((v) => v.detail === currentField.unit)
: undefined;
var bgcolor = undefined;
var radioBGcolor = colorchoice.find((v) => v.code === "none");
var color = undefined;
var radioColor = colorchoice.find((v) => v.code === "none");
var textsize = undefined;
var radioSize = colorchoice.find((v) => v.code === "none");
var minwidth = undefined;
var radioWidth = colorchoice.find((v) => v.code === "none");
var radioMaxWidth = colorchoice.find((v) => v.code === "none");
var maxwidth = undefined;
var selectAlign = undefined;
var radioAlign = colorchoice.find((v) => v.code === "none");
var radioTemplate = ref(
colorchoice.find((v) => v.code === (currentField.template ? "option" : "none"))["code"]
);
var selectPlacement = store.placement.find((v) => v.code === "is-right");
var selectScheme = store.colorscheme.find((v) => v.code === "is-primary");
var radioTooltip = store.colorchoice.find((v) => v.code === "none");
var selectField = undefined;
var tags = currentField.tags
? currentField.tags.map((v) => fields.find((x) => x.name === v))
: [];
var formula = currentField.formula ? currentField.formula : undefined;
var decimal = currentField.decimal;
let shortmenu = store.menuchoice.filter((x) =>
currentField.format === "number"
? currentField.formula
? true
: x.code !== "formula"
: !["filter", "formula"].find((y) => y === x.code)
);
var selectTab = shortmenu.find((v) => selectTab.code === v.code)
? selectTab
: menuchoice.find((v) => v.code === "value");
var search = undefined;
// if(selectTab.code==='value') {
// let self = this
// setTimeout(function() {self.$refs[currentField.name]? self.$refs[currentField.name].focus() : false}, 50)
// }
//==============================================================
function moveLeft() {
let copy = $clone(pagedata);
let i = copy.fields.findIndex((v) => v.name === props.field.name);
let idx = i - 1 >= 0 ? i - 1 : copy.fields.length - 1;
$arrayMove(copy.fields, i, idx);
copy.update = { fields: copy.fields };
store.commit(props.pagename, copy);
emit("changepos");
}
function moveRight() {
let copy = $clone(pagedata);
let i = copy.fields.findIndex((v) => v.name === props.field.name);
let idx = copy.fields.length - 1 > i ? i + 1 : 0;
$arrayMove(copy.fields, i, idx);
copy.update = { fields: copy.fields };
store.commit(props.pagename, copy);
emit("changepos");
}
function hideField() {
let copy = $clone(store[props.pagename]);
let found = copy.fields.find((v) => v.name === props.field.name);
found.show = false;
copy.update = { fields: copy.fields };
store.commit(props.pagename, copy);
emit("close");
}
function doRemove() {
let copy = $clone(store[props.pagename]);
let idx = copy.fields.findIndex((v) => v.name === props.field.name);
copy.fields.splice(idx, 1);
copy.update = { fields: copy.fields };
store.commit(props.pagename, copy);
emit("close");
}
function fieldList() {
showmodal.value = {
component: "datatable/TableOption",
vbind: { pagename: props.pagename },
title: "Danh sách cột",
width: "50%",
height: "630px",
};
}
function tableOption() {
showmodal.value = {
component: "datatable/TableSetting",
vbind: { pagename: props.pagename },
title: "Tùy chọn bảng",
width: "40%",
height: "400px",
};
}
const getFields = function () {
fields = pagedata ? $copy(pagedata.fields) : [];
fields.map(
(v) => (v.caption = (v.label ? v.label.indexOf("<") >= 0 : false) ? v.name : v.label)
);
};
const doSelect = function (evt) {
emit("modalevent", { name: "selected", data: evt[props.field.name] });
};
const changeLabel = function (text) {
currentField.label = text;
updateFields(currentField);
};
function editLabel() {
showmodal.value = {
component: "datatable/EditLabel",
width: "500px",
height: "300px",
vbind: { label: label },
};
}
const changeTemplate = function (v) {
radioTemplate.value = v.code;
let copy = $copy(currentField);
copy.template = v.code;
updateFields(copy);
};
function createField() {
showmodal.value = {
component: "datatable/NewField",
vbind: { pagename: props.pagename },
title: "Tạo cột mới",
width: "50%",
height: "630px",
};
}
function copyContent(value) {
$copyToClipboard(value);
}
function close() {
showmodal.value = undefined;
}
const updateFields = function (field, type) {
let copy = $clone(store[props.pagename]);
let idx = copy.fields.findIndex((v) => v.name === field.name);
copy.fields[idx] = field;
store.commit(props.pagename, copy);
};
const changeTab = function (v) {
currentTab.value = v.code;
selectTab = v;
};
const saveSetting = function () {
showmodal.value = {
component: "datatable/MenuSave",
vbind: { pagename: props.pagename, classify: 4 },
title: "Lưu thiết lập",
width: "500px",
height: "400px",
};
};
const showSidebar = function () {
let event = { name: "template", field: currentField };
let title = "Danh sách cột";
if (event.name === "bgcolor")
title = `Đổi màu nền: ${event.field.name} / ${$stripHtml(event.field.label, 30)}`;
else if (event.name === "color")
title = `Đổi màu chữ: ${event.field.name} / ${$stripHtml(event.field.label, 30)}`;
else if (event.name === "template")
title = `Định dạng nâng cao: ${$stripHtml(event.field.label, 30)}`;
showmodal.value = {
component: "datatable/FormatOption",
vbind: { event: event, currentField: currentField, pagename: props.pagename },
width: "850px",
height: "700px",
title: title,
};
};
function resizeWidth(minus) {
let val = maxwidth || minwidth || 80;
val = minus ? parseInt(val - 0.1 * val) : parseInt(val + 0.1 * val);
if (val > 1000) return $snackbar("Độ rộng cột lớn hơn giới hạn cho phép");
else if (val < 20) return $snackbar("Độ rộng cột nhỏ hơn giới hạn cho phép");
radioMaxWidth = store.colorchoice.find((v) => v.code === "option");
radioWidth = store.colorchoice.find((v) => v.code === "option");
maxwidth = val;
currentField.maxwidth = val;
minwidth = val;
currentField.minwidth = val;
updateFields(currentField);
}
</script>

View File

@@ -0,0 +1,431 @@
<template>
<div v-if="docid">
<div class="field is-horizontal">
<div class="field-body">
<div class="field">
<label class="label">Đối tượng</label>
<p class="control fs-14">
<b-radio v-for="(v,i) in types" :key="i" v-model="type"
:native-value="v" @input="changeType(v)">
{{v.name}}
</b-radio>
</p>
</div>
<div class="field">
<label class="label">Kích cỡ</label>
<p class="control fs-14">
<b-radio v-for="(v,i) in sizes.filter(v=>type? (type.code==='tag'? v.code!=='is-small' : 1>0) : true)" :key="i" v-model="size"
:native-value="v" @input="changeType(v)">
{{v.name}}
</b-radio>
</p>
</div>
<div class="field">
<label class="label" v-if="['tag'].find(v=>v===type.code)">Hình khối</label>
<p class="control fs-14" v-if="['tag'].find(v=>v===type.code)">
<b-radio v-for="(v,i) in shapes" :key="i" v-model="shape"
:native-value="v" @input="changeType(v)">
{{v.name}}
</b-radio>
</p>
</div>
</div>
</div>
<div class="field is-horizontal" v-if="['tag'].find(v=>v===type.code)">
<div class="field-body">
<div class="field" v-if="type.code!=='tag'">
<label class="label">Outline</label>
<p class="control fs-14">
<b-radio v-for="(v,i) in outlines" :key="i" v-model="outline"
:native-value="v" @input="changeType(v)">
{{v.name}}
</b-radio>
</p>
</div>
</div>
</div>
<div class="tags" v-if="type.code==='tag'">
<a :class="getClass(v)" v-for="(v,i) in colorscheme" :key="i"
@click="doSelect(v)" :ref="'tag' + i"> {{v.name}} </a>
</div>
<div class="pt-2" v-else-if="type.code==='span'">
<a class="mr-3" :class="getSpanClass(v)" v-for="(v,i) in colorscheme" :key="i"
@click="doSelectSpan(v)" :ref="'span' + i"> {{v.name}} </a>
</div>
<div :class="`tabs is-boxed mt-5 mb-5 ${tab.code==='template'? '' : 'pb-2'}`">
<ul>
<li :class="tab.code===v.code? 'is-active' : ''"
v-for="(v,i) in tabs" :key="i" @click="changeTab(v)"><a class="fs-15">{{v.name}}</a>
</li>
</ul>
</div>
<template v-if="tab.code==='selected'">
<a v-for="(v,i) in tags" :key="i" @click="selected=v">
<div class="field is-grouped is-grouped-multiline mt-4">
<p class="control">
<a :class="v.class">
{{v.name}}
</a>
</p>
<p class="control">
<input class="input is-small" type="text" v-model="v.name">
</p>
<p class="control">
<a @click="remove(i)">
<SvgIcon v-bind="{name: 'close.svg', type: 'danger', size: 22}"></SvgIcon>
</a>
</p>
<p class="control has-text-right ml-5" v-if="selected? selected.id===v.id : false">
<SvgIcon v-bind="{name: 'tick.svg', type: 'primary', size: 22}"></SvgIcon>
</p>
</div>
</a>
</template>
<template v-else-if="tab.code==='condition'">
<div class="mb-5" v-if="selected">
<b-radio v-for="(v,i) in conditions" :key="i" v-model="condition"
:native-value="v" @input="changeCondition(v)">
{{v.name}}
</b-radio>
</div>
<template v-if="condition? condition.code==='yes' : false">
<div class="field mt-3">
<label class="label fs-14">Chọn trường xây dựng biểu thức <span class="has-text-danger"> * </span> </label>
<div class="control">
<b-taginput
size="is-small"
v-model="tagsField"
:data="pageData? pageData.fields.filter(v=>v.format==='number') : []"
type="is-dark is-light"
autocomplete
:open-on-focus="true"
field="name"
icon="plus"
placeholder="Chọn trường"
>
<template slot-scope="props">
<span class="mr-3 has-text-danger has-text-weight-bold"> {{props.option.name}}</span>
<span :class="tagsField.find(v=>v.id===props.option.id)? 'has-text-dark' : ''"> {{$stripHtml(props.option.label, 50)}} </span>
</template>
<template slot="empty">
Không trường thỏa mãn
</template>
</b-taginput>
</div>
<p class="help has-text-danger" v-if="errors.find(v=>v.name==='tagsField')"> {{errors.find(v=>v.name==='tagsField').message}} </p>
</div>
<div class="field mt-1" v-if="tagsField.length>0">
<p class="help is-primary"> Click đúp vào để thêm vào biểu thức.</p>
<div class="tagsField">
<a @dblclick="expression = expression? (expression + ' ' + v.name) : v.name"
class="tag is-rounded" v-for="(v,i) in tagsField" :key="i">
<span class="tooltip">
{{v.name}}
<span class="tooltiptext">{{ $stripHtml(v.label) }}</span>
</span>
</a>
</div>
</div>
<div class="field">
<label class="label fs-14">Biểu thức dạng Đúng / Sai <span class="has-text-danger"> * </span> </label>
<p class="control is-expanded">
<input class="input" type="text" v-model="expression" placeholder="Tạo biểu thức tại đây">
</p>
<p class="help has-text-danger" v-if="errors.find(v=>v.name==='expression')"> {{errors.find(v=>v.name==='expression').message}} </p>
</div>
</template>
</template>
<template v-else-if="tab.code==='option' && selected">
<div class="field is-horizontal border-bottom pb-2 mt-1">
<div class="field-body">
<div class="field">
<label class="label fs-14">Màu nền </label>
<p class="control fs-14">
<b-radio v-for="(v,i) in colorchoice.filter(v=>v.code!=='condition')" :key="i" v-model="radioBGcolor"
:native-value="v" @input="changeStyle()">
{{v.name}}
</b-radio>
</p>
</div>
<div class="field" v-if="radioBGcolor? radioBGcolor.code==='option' : false">
<label class="label fs-14"> màu <span class="has-text-danger"> * </span> </label>
<p class="control fs-14">
<input type="color" v-model="bgcolor" @change="changeStyle()">
</p>
</div>
</div>
</div>
<div class="field is-horizontal border-bottom pb-2">
<div class="field-body">
<div class="field">
<label class="label fs-14">Màu chữ </label>
<p class="control fs-14">
<b-radio v-for="(v,i) in colorchoice.filter(v=>v.code!=='condition')" :key="i" v-model="radioColor"
:native-value="v" @input="changeStyle()">
{{v.name}}
</b-radio>
</p>
</div>
<div class="field" v-if="radioColor? radioColor.code==='option' : false">
<label class="label fs-14"> màu <span class="has-text-danger"> * </span> </label>
<p class="control fs-14">
<input type="color" v-model="color" @change="changeStyle()">
</p>
</div>
</div>
</div>
<div class="field is-horizontal border-bottom pb-2">
<div class="field-body">
<div class="field">
<label class="label fs-14">Cỡ chữ </label>
<p class="control fs-14">
<b-radio v-for="(v,i) in colorchoice.filter(v=>v.code!=='condition')" :key="i" v-model="radioSize"
:native-value="v" @input="changeStyle()">
{{v.name}}
</b-radio>
</p>
</div>
<div class="field" v-if="radioSize? radioSize.code==='option' : false">
<label class="label fs-14"> Cỡ chữ <span class="has-text-danger"> * </span> </label>
<p class="control fs-14">
<input class="input is-small" type="text" placeholder="Nhập số" v-model="textsize" @change="changeStyle()">
</p>
</div>
</div>
</div>
</template>
<template v-else-if="tab.code==='template'">
<p class="mb-3">
<a @click="copyContent()" class="mr-6">
<span class="icon-text">
<SvgIcon class="mr-2" v-bind="{name: 'copy.svg', type: 'primary', siz: 18}"></SvgIcon>
<span class="fs-16">Copy</span>
</span>
</a>
<a @click="paste()" class="mr-6">
<span class="icon-text">
<SvgIcon class="mr-2" v-bind="{name: 'pen1.svg', type: 'primary', siz: 18}"></SvgIcon>
<span class="fs-16">Paste</span>
</span>
</a>
</p>
<div>
<textarea class="textarea fs-14" rows="8" v-model="text" @dblclick="doCheck"></textarea>
</div>
<p class="mt-5">
<span class="icon-text fsb-18">
Replace
<SvgIcon v-bind="{name: 'right.svg', type: 'dark', size: 22}"></SvgIcon>
</span>
</p>
<div class="field is-grouped mt-4">
<div class="control">
<p class="fsb-14 mb-1">Đoạn text</p>
<input class="input" type="text" placeholder="" v-model="source">
</div>
<div class="control">
<p class="fsb-14 mb-1">Thay bằng</p>
<input class="input" type="text" placeholder="" v-model="target">
</div>
<div class="control pl-5">
<button class="button is-primary is-outlined mt-5" @click="replace()">Replace</button>
</div>
</div>
<p class="mt-5">
<button class="button is-primary has-text-white" @click="changeTemplate()">Áp dụng</button>
</p>
</template>
</div>
</template>
<script setup>
import { ref } from 'vue';
import { useStore } from '@/stores/index'
const store = useStore()
const { $id, $copy, $empty, $stripHtml, $calc, $remove, $copyToClipboard } = useNuxtApp()
var props = defineProps({
pagename: String,
field: Object
})
var colorscheme = store.colorscheme
var colorchoice = store.colorchoice
var pageData = store[props.pagename]
var field = props.field
var type = undefined
var size = undefined
var types = [{code: 'span', name: 'span'}, {code: 'tag', name: 'tag'}]
var sizes = [{code: 'is-small', name: 'Nhỏ', value: 'is-size-6'}, {code: 'is-normal', name: 'Trung bình', value: 'is-size-5'},
{code: 'is-medium', name: 'Lớn', value: 'is-size-4'}]
var shapes = [{code: 'default', name: 'Mặc định'}, {code: 'is-rounded', name: 'Tròn góc'}]
var shape = undefined
var outlines = [{code: 'default', name: 'Mặc định'}, {code: 'is-outlined', name: 'Outline'}]
var outline = undefined
var conditions = [{code: 'no', name: 'Không áp dụng'}, {code: 'yes', name: 'Có áp dụng'}]
var condition = undefined
var tags = []
var selected = undefined
var tabs = [{code: 'selected', name: 'Bước 1: Tạo nội dung'}, {code: 'condition', name: 'Bước 2: Đặt điều kiện'}, {code: 'option', name: 'Bước 3: Chọn màu, cỡ chữ'},
{code: 'template', name: 'Bước 4: Mã lệnh & áp dụng'}]
var tab = ref(undefined)
var tagsField = []
var errors = []
var expression = ''
var text = ref(null)
var radioBGcolor = undefined
var radioColor = undefined
var radioSize = undefined
var bgcolor = undefined
var color = undefined
var textsize = undefined
var source = undefined
var target = $copy(field.name)
const initData = function() {
type = types.find(v=>v.code==='tag')
size = sizes.find(v=>v.code==='is-normal')
shape = shapes.find(v=>v.code==='is-rounded')
outline = shapes.find(v=>v.code==='default')
if($empty(field.template)) tab.value =tabs.find(v=>v.code==='selected')
else {
text.value =$copy(field.template)
tab.value =tabs.find(v=>v.code==='template')
}
condition =conditions.find(v=>v.code==='no')
}
/*watch: {
expression: function(newVal) {
if($empty(newVal)) return
elsecheckExpression()
},
tab: function(newVal, oldVal) {
if(oldVal===undefined) return
if(newVal.code==='template') {
let value = '<div>'
tags.map((v,i)=>{
value += '<span class="' + v.class + (tags.length>i+1? ' mr-2' : '') + '" '
if(v.style) value += 'style="' + v.style + '" '
value += (v.expression? ' v-if="' + v.expression + '"' : '') + '>' + v.name + '</span>'
})
value += '</div>'
text = value
} else if(newVal.code==='option') {
if(!selected) return
radioBGcolor =selected.bgcolor?colorchoice.find(v=>v.code==='option') :colorchoice.find(v=>v.code==='none')
radioColor =selected.color?colorchoice.find(v=>v.code==='option') :colorchoice.find(v=>v.code==='none')
radioSize =selected.textsize?colorchoice.find(v=>v.code==='option') :colorchoice.find(v=>v.code==='none')
bgcolor =selected.bgcolor?selected.bgcolor : undefined
color =selected.color?selected.color : undefined
textsize =selected.textsize?selected.textsize : undefined
} else if(newVal.code==='condition') {
condition = conditions.find(v=>v.code==='no')
tagsField = []
expression = ''
if(selected?selected.expression : false) {
condition =conditions.find(v=>v.code==='yes')
tagsField =$copy(selected.tags)
expression =$copy(selected.formula)
}
}
}
},*/
function changeTab(v) {
tab.value = v
}
const paste = async function() {
text.value = await navigator.clipboard.readText()
}
const replace = function() {
if($empty(text.value)) return
text.value =text.value.replaceAll(source,target)
}
const doCheck = function() {
let text = window.getSelection().toString()
if($empty(text)) return
source = text
}
const changeStyle = function() {
selected.bgcolor =selected.color =selected.textsize =selected.style = undefined
let style = ''
if(radioBGcolor.code==='option'? !$empty(bgcolor) : false) {
selected.bgcolor =bgcolor
style += 'background-color: ' +bgcolor + ' !important; '
}
if(radioColor.code==='option'? !$empty(color) : false) {
selected.color =color
style += 'color: ' +color + ' !important; '
}
if(radioSize.code==='option'?$isNumber(textsize) : false) {
selected.textsize =textsize
style += 'font-size: ' +textsize + 'px !important; '
}
$empty(style)? false :selected.style = style
}
const changeCondition = function(v) {
if(v.code==='no')selected.expression = undefined
}
const copyContent = function() {
$copyToClipboard(text.value)
}
const changeTemplate = function() {
let copy = pageData
let found = copy.fields.find(v=>v.name===field.name)
found.template = text.value
store.commit(props.pagename, copy)
}
const checkExpression = function() {
errors = []
let val =$copy(expression)
let exp =$copy(expression)
tagsField.forEach(v => {
let myRegExp = new RegExp(v.name, 'g')
val = val.replace(myRegExp, Math.random())
exp = exp.replace(myRegExp, "formatNumber(row['" + v.name + "'])")
})
try {
let value =$calc(val)
if(isNaN(value) || value===Number.POSITIVE_INFINITY || value===Number.NEGATIVE_INFINITY) {
errors.push({name: 'expression', message: 'Biểu thức không hợp lệ'})
} else if(!(eval(value)===true || eval(value)===false)) {
errors.push({name: 'expression', message: 'Biểu thức không hợp lệ'})
} else if(selected) {
selected.expression = exp
selected.formula =expression
selected.tags =$copy(tagsField)
}
}
catch(err) {
errors.push({name: 'expression', message: 'Biểu thức không hợp lệ'})
}
returnerrors.length>0? false : true
}
const changeType = function(v) {
}
const doSelect = function(v) {
tags.push({id:$id(), name: v.name, class:getClass(v)})
tab =tabs.find(v=>v.code==='selected')
selected =tags[tags.length-1]
}
const doSelectSpan = function(v) {
tags.push({id:$id(), name: v.name, class:getSpanClass(v)})
tab =tabs.find(v=>v.code==='selected')
selected =tags[tags.length-1]
}
const remove = function(i) {
$remove(tags, i)
}
const getClass = function(v) {
let value =type.code + ' ' + v.code + ' ' +size.code + (shape.code==='default'? '' : ' ' +shape.code)
value += (outline.code==='default'? '' : ' ' +outline.code)
return value
}
const getSpanClass = function(v) {
let value = 'has-text-' + v.name.toLowerCase() + ' ' +size.value
return value
}
initData()
var docid = $id()
</script>

View File

@@ -0,0 +1,193 @@
<template>
<div class="columns mx-0">
<div class="column is-2">
<Caption class="mb-2" v-bind="{title: 'Tên model (bảng)', type: 'has-text-warning'}"></Caption>
<div class="mb-2">
<input class="input" v-model="text" placeholder="Tìm model" @change="findModel()">
</div>
<div style="max-height: 80vh; overflow: auto;">
<div :class="`py-1 border-bottom is-clickable ${current.model===v.model? 'has-background-primary has-text-white' : ''}`"
v-for="v in displayData" @click="changeMenu(v)">
{{ v.model}}
</div>
</div>
</div>
<div class="column is-10 py-0 px-0">
<div class="tabs mb-3">
<ul>
<li :class="`${v.code===tab? 'is-active has-text-weight-bold fs-18' : 'fs-18'}`" v-for="v in tabs">
<a @click="changeTab(v)">{{v.name}}</a>
</li>
</ul>
</div>
<div v-if="tab==='datatype'">
<Caption class="mb-2" v-bind="{title: 'Kiểu dữ liệu (type)', type: 'has-text-warning'}"></Caption>
<div style="max-height:75vh; overflow-y: auto;">
<div class="py-1 border-bottom is-clickable" v-for="x in current.fields">
{{ x.name}}
<span class="ml-6 has-text-grey">{{ x.type }}</span>
<a class="ml-6 has-text-primary" v-if="x.model" @click="openModel(x)">{{ x.model }}</a>
</div>
</div>
</div>
<template v-else-if="tab==='table'">
<div class="columns mx-0 mb-0 pb-0">
<div class="column is-5">
<Caption class="mb-1" v-bind="{title: 'Values', type: 'has-text-warning'}"></Caption>
<input class="input" rows="1" v-model="values" placeholder="Tên trường không chứa dấu cách, vd: code,name">
</div>
<div class="column is-4">
<Caption class="mb-1" v-bind="{title: 'Filter', type: 'has-text-warning'}"></Caption>
<input class="input" rows="1" v-model="filter" placeholder="{'code': 'xyz'}">
</div>
<div class="column is-2">
<Caption class="mb-1" v-bind="{title: 'Sort', type: 'has-text-warning'}"></Caption>
<input class="input" rows="1" v-model="sort" placeholder="vd: -code,name">
</div>
<div class="column is-1">
<Caption class="mb-1" v-bind="{title: 'Load', type: 'has-text-warning'}"></Caption>
<div>
<button class="button is-primary has-text-white" @click="loadData()">Load</button>
</div>
</div>
</div>
<Caption class="mb-1" v-bind="{title: 'Query', type: 'has-text-warning'}"></Caption>
<div class="mb-2">
{{ query }}
<a class="has-text-primary ml-5" @click="copy()">copy</a>
<p>{{apiUrl}}
<a class="has-text-primary ml-5" @click="$copyToClipboard(apiUrl)">copy</a>
<a class="has-text-primary ml-5" target="_blank" :href="apiUrl">open</a>
</p>
</div>
<div>
<DataTable v-bind="{pagename: pagename}" v-if="pagedata" />
</div>
</template>
<div v-else>
<img id="image" :src="filePath" alt="">
<p class="pl-5">
<a class="mr-5" @click="downloadFile()">
<SvgIcon v-bind="{name: 'download.svg', type: 'black', size: 24}"></SvgIcon>
</a>
<a target="_blank" :href="filePath">
<SvgIcon v-bind="{name: 'open.svg', type: 'black', size: 24}"></SvgIcon>
</a>
</p>
</div>
</div>
</div>
<Modal @close="showmodal=undefined" v-bind="showmodal" v-if="showmodal"></Modal>
</template>
<script setup>
import { useStore } from '@/stores/index'
const { $getdata, $getapi, $createField, $clone, $getpage, $empty, $copyToClipboard, $find, $multiSort, $download, $getpath } = useNuxtApp()
const store = useStore()
var pagename = 'pagedata3'
var pagedata = ref()
pagedata.value = $getpage()
pagedata.value.perPage = 10
store.commit(pagename, pagedata)
let list = ['LogEntry', 'Permission', 'ContentType', 'Session', 'Group']
var data = (await $getdata('getmodel')).filter(v=>list.findIndex(x=>x===v.model)<0)
data = $multiSort(data, {model: 'asc'})
var current = ref({fields: []})
var tabs = [{code: 'datatype', name: 'Kiểu dữ liệu'}, {code: 'table', name: 'Dữ liệu'}, {code: 'datamodel', name: 'Data model'}]
var tab = ref('datatype')
var datatable = ref()
var query = ref()
var values, filter
var apiUrl = ref()
var showmodal = ref()
var text = null
var displayData = ref(data)
var filePath = `${$getpath()}static/files/datamodel.png`
var sort = "-id"
current.value = data[0]
function changeMenu(v) {
values = undefined
filter = undefined
sort = undefined
current.value = v
if(tab.value==='table') loadData()
}
async function changeTab(v) {
tab.value = v.code
if(v.code==='table') loadData()
}
async function loadData() {
let vfilter = filter? filter.trim() : undefined
if(vfilter) {
try {
vfilter = JSON.parse(vfilter)
} catch (error) {
alert('Cấu trúc filter lỗi')
vfilter = undefined
}
}
let params = {values: $empty(values)? undefined : values.trim(), filter: filter, sort: $empty(sort)? undefined : sort.trim()}
let modelName = current.value.model
let found = {name: modelName.toLowerCase().replace('_', ''), url: `data/${modelName}/`, url_detail: `data-detail/${modelName}/`, params: params}
query.value = $clone(found)
let rs = await $getapi([found])
if(rs==='error') return alert('Đã xảy ra lỗi, hãy xem lại câu lệnh.')
datatable.value = rs[0].data.rows
showData()
// api query
const baseUrl = $getpath() + `${query.value.url}`
apiUrl.value = baseUrl
let vparams = !$empty(values)? {values: values} : null
if(!$empty(filter)) {
vparams = vparams? {values: values, filter:filter} : {filter:filter}
}
if(!$empty(sort)) {
if(vparams) {
vparams.sort = sort.trim()
} else {
vparams = {sort: sort.trim()}
}
}
if(vparams) {
let url = new URL(baseUrl);
let searchParams = new URLSearchParams(vparams);
url.search = searchParams.toString();
apiUrl.value = baseUrl + url.search
}
}
function showData() {
let arr = []
if(!$empty(values)) {
let arr1 = values.trim().split(',')
arr1.map(v=>{
let val = v.trim()
let field = $createField(val, val, 'string', true)
arr.push(field)
})
} else {
current.value.fields.map(v=>{
let field = $createField(v.name, v.name, 'string', true)
arr.push(field)
})
}
let clone = $clone(pagedata.value)
clone.fields = arr
clone.data = datatable.value
pagedata.value = undefined
setTimeout(()=>pagedata.value = clone)
}
function copy() {
$copyToClipboard(JSON.stringify(query.value))
}
function openModel(x) {
showmodal.value = {component: 'datatable/ModelInfo', title: x.model, width: '70%', height: '600px',
vbind: {data: data, info: $find(data, {model: x.model})}}
}
function downloadFile() {
$download(`${$getpath()}download/?name=datamodel.png&type=file`, 'datamodel.png')
}
function findModel() {
if($empty(text)) return displayData.value = data
displayData.value = data.filter(v=>v.model.toLowerCase().indexOf(text.toLowerCase())>=0)
}
</script>

View File

@@ -0,0 +1,550 @@
<template>
<div class="field is-grouped is-grouped-multiline pl-2" v-if="filters? filters.length>0 : false">
<div class="control mr-5">
<a class="button is-primary is-small has-text-white has-text-weight-bold" @click="updateData({filters: []})">
<span class="fs-14">Xóa lọc</span>
</a>
</div>
<div class="control pr-2 mr-5">
<span class="icon-text">
<SvgIcon v-bind="{name: 'sigma.svg', type: 'primary', size: 20}"></SvgIcon>
<span class="fsb-18 has-text-primary">{{totalRows}}</span>
</span>
</div>
<div class="control" v-for="(v,i) in filters" :key="i">
<div class="tags has-addons is-marginless">
<a class="tag is-primary has-text-white is-marginless" @click="showCondition(v)">{{v.label.indexOf('>')>=0? $stripHtml(v.label,30) : v.label}}</a>
<a class="tag is-delete is-marginless has-text-black-bis" @click="removeFilter(i)"></a>
</div>
<span class="help has-text-black-bis">
{{v.sort? v.sort : (v.select? ('[' + (v.select.length>0? $stripHtml(v.select[0],20) : '') + '...&#931;' + v.select.length + ']') :
(v.condition))}}</span>
</div>
</div>
<div class="table-container mb-0" ref="container" id="docid">
<table class="table is-fullwidth is-bordered is-narrow is-hoverable" :style="tableStyle">
<thead>
<tr>
<th v-for="(field,i) in displayFields" :key="i" :style="field.headerStyle">
<div @click="showField(field)" :style="field.dropStyle">
<a v-if="field.label.indexOf('<')<0">{{field.label}}</a>
<a v-else>
<component :is="dynamicComponent(field.label)" :row="v" @clickevent="clickEvent($event, v, field)" />
</a>
</div>
</th>
</tr>
</thead>
<tbody>
<tr v-for="(v,i) in displayData" :key="i">
<td
v-for="(field, j) in displayFields"
:key="j"
:id="field.name"
:style="v[`${field.name}color`]"
style="
overflow: hidden;
text-overflow: ellipsis;
"
@dblclick="doubleClick(field, v)">
<component :is="dynamicComponent(field.template)" :row="v" v-if="field.template" @clickevent="clickEvent($event, v, field)" />
<span v-else>{{ v[field.name] }}</span>
</td>
</tr>
</tbody>
</table>
<DatatablePagination
v-bind="{ data: data, perPage: perPage }"
@changepage="changePage"
v-if="showPaging"
></DatatablePagination>
</div>
<Modal
@close="close"
@selected="doSelect"
@confirm="confirmRemove"
v-bind="showmodal"
v-if="showmodal"
></Modal>
</template>
<script setup>
import { createApp } from "vue/dist/vue.esm-bundler.js";
import { ref, defineComponent } from "vue";
import { useStore } from "~/stores/index";
const emit = defineEmits(["edit", "insert", "dataevent"]);
const {
$calc,
$calculate,
$calculateData,
$copy,
$deleterow,
$empty,
$find,
$getEditRights,
$formatNumber,
$multiSort,
$remove,
$stripHtml,
$unique,
} = useNuxtApp();
const store = useStore();
var props = defineProps({
pagename: String,
});
function dynamicComponent(htmlString) {
return defineComponent({
template: htmlString,
props: {
row: {
type: Object,
default: () => ({}),
},
},
});
}
var timer = undefined;
var showPaging = ref(false);
var totalRows = ref(0);
var currentPage = 1;
var displayFields = ref([]);
var displayData = [];
var pagedata = store[props.pagename];
var tablesetting = $copy(pagedata.tablesetting || store.tablesetting);
var perPage = Number($find(tablesetting, { code: "per-page" }, "detail")) || 20;
var filters = $copy(pagedata.filters || []);
var currentField;
var filterData = [];
var currentsetting;
var scrollbar;
var fields;
var currentRow;
var data = $copy(pagedata.data);
var showmodal = ref();
watch(
() => store[props.pagename],
(newVal, oldVal) => {
updateChange();
}
);
function updateChange() {
pagedata = store[props.pagename];
if (!pagedata.update) return;
if (pagedata.update.data) data = $copy(pagedata.update.data);
if (pagedata.update.filters) {
doFilter(pagedata.update.filters);
updateShow();
return; //exit
}
if (filters.length > 0) doFilter(filters);
if (pagedata.update.fields || pagedata.update.data) updateShow();
}
const updateShow = function (full_data) {
// allowed JS expressions - should return a boolean
const allowedFns = {
'$getEditRights()': $getEditRights,
};
const arr = pagedata.fields.filter(({ show }) => {
if (typeof show === 'boolean') return show;
else {
// show is a string
if (show === 'true') return true;
if (show === 'false') return false;
return allowedFns[show]?.() || false;
}
});
if (full_data === false) displayData = $copy(data);
else
displayData = $copy(
data.filter(
(ele, index) =>
index >= (currentPage - 1) * perPage && index < currentPage * perPage
)
);
displayData.map((v) => {
arr.map((x) => (v[`${x.name}color`] = getStyle(x, v)));
});
arr.map((v) => {
v.headerStyle = getSettingStyle("header", v);
v.dropStyle = getSettingStyle("dropdown", v);
});
displayFields.value = arr;
showPagination();
};
function confirmRemove() {
$deleterow(pagedata.api.name, currentRow.id, props.pagename, true);
}
const clickEvent = function (event, row, field) {
let name = typeof event === "string" ? event : event.name;
let data = typeof event === "string" ? event : event.data;
if (name === "remove") {
currentRow = row;
showmodal.value = {
component: `dialog/Confirm`,
vbind: { content: "Bạn có muốn xóa bản ghi này không?", duration: 10 },
title: "Xác nhận",
width: "500px",
height: "100px",
};
}
emit(name, row, field, data);
};
const showField = async function (field) {
if (pagedata.contextMenu === false || field.menu === "no") return;
currentField = field;
filterData = $unique(pagedata.data, [field.name]);
//let doc = this.$refs[`th${field.name}`]
//let width = (doc? doc.length>0 : false)? doc[0].getBoundingClientRect().width : 100
let width = 100;
if (pagedata.setting) currentsetting = $copy(pagedata.setting);
showmodal.value = {
vbind: {
pagename: props.pagename,
field: field,
filters: filters,
filterData: filterData,
width: width,
},
component: "datatable/ContextMenu",
title: field.name,
width: "650px",
height: "500px",
}; //$stripHtml(field.label)
};
const getStyle = function (field, record) {
var stop = false;
let val = tablesetting.find((v) => v.code === "td-border")
? tablesetting.find((v) => v.code === "td-border").detail
: "border: solid 1px rgb(44, 44, 44); ";
val = val.indexOf(";") >= 0 ? val : val + ";";
if (field.bgcolor ? !Array.isArray(field.bgcolor) : false) {
val += ` background-color:${field.bgcolor}; `;
} else if (field.bgcolor ? Array.isArray(field.bgcolor) : false) {
field.bgcolor.map((v) => {
if (v.type === "search") {
if (
record[field.name] && !stop
? record[field.name].toLowerCase().indexOf(v.keyword.toLowerCase()) >= 0
: false
) {
val += ` background-color:${v.color}; `;
stop = true;
}
} else {
let res = $calculate(record, v.tags, v.expression);
if (res.success && res.value && !stop) {
val += ` background-color:${v.color}; `;
stop = true;
}
}
});
}
stop = false;
if (field.color ? !Array.isArray(field.color) : false) {
val += ` color:${field.color}; `;
} else if (field.color ? Array.isArray(field.color) : false) {
field.color.map((v) => {
if (v.type === "search") {
if (
record[field.name] && !stop
? record[field.name].toLowerCase().indexOf(v.keyword.toLowerCase()) >= 0
: false
) {
val += ` color:${v.color}; `;
stop = true;
}
} else {
let res = $calculate(record, v.tags, v.expression);
if (res.success && res.value && !stop) {
val += ` color:${v.color}; `;
stop = true;
}
}
});
}
stop = false;
if (field.textsize ? !Array.isArray(field.textsize) : false) {
val += ` font-size:${field.textsize}px; `;
} else if (field.textsize ? Array.isArray(field.textsize) : false) {
field.textsize.map((v) => {
if (v.type === "search") {
if (
record[field.name] && !stop
? record[field.name].toLowerCase().indexOf(v.keyword.toLowerCase()) >= 0
: false
) {
val += ` font-size:${v.size}px; `;
stop = true;
}
} else {
let res = $calculate(record, v.tags, v.expression);
if (res.success && res.value && !stop) {
val += ` font-size:${v.size}px; `;
stop = true;
}
}
});
} else
val += ` font-size:${
tablesetting.find((v) => v.code === "table-font-size").detail
}px;`;
if (field.textalign) val += ` text-align:${field.textalign}; `;
if (field.minwidth) val += ` min-width:${field.minwidth}px; `;
if (field.maxwidth) val += ` max-width:${field.maxwidth}px; `;
return val;
};
const getSettingStyle = function (name, field) {
let value = "";
if (name === "container") {
value =
"min-height:" +
tablesetting.find((v) => v.code === "container-height").detail +
"rem; ";
} else if (name === "table") {
value +=
"background-color:" +
tablesetting.find((v) => v.code === "table-background").detail +
"; ";
value +=
"font-size:" +
tablesetting.find((v) => v.code === "table-font-size").detail +
"px;";
value +=
"color:" + tablesetting.find((v) => v.code === "table-font-color").detail + "; ";
} else if (name === "header") {
value +=
"background-color:" +
tablesetting.find((v) => v.code === "header-background").detail +
"; ";
if (field.minwidth) value += " min-width: " + field.minwidth + "px; ";
if (field.maxwidth) value += " max-width: " + field.maxwidth + "px; ";
} else if (name === "menu") {
let arg = tablesetting.find((v) => v.code === "menu-width").detail;
arg = field ? (field.menuwidth ? field.menuwidth : arg) : arg;
value += "width:" + arg + "rem; ";
value +=
"min-height:" +
tablesetting.find((v) => v.code === "menu-min-height").detail +
"rem; ";
value +=
"max-height:" +
tablesetting.find((v) => v.code === "menu-max-height").detail +
"rem; ";
value += "overflow:auto; ";
} else if (name === "dropdown") {
value +=
"font-size:" +
tablesetting.find((v) => v.code === "header-font-size").detail +
"px; ";
let found = filters.find((v) => v.name === field.name);
found
? (value +=
"color:" +
tablesetting.find((v) => v.code === "header-filter-color").detail +
"; ")
: (value +=
"color:" +
tablesetting.find((v) => v.code === "header-font-color").detail +
"; ");
}
return value;
};
function changePage(page) {
currentPage = page;
updateShow();
}
const showPagination = function () {
showPaging.value = pagedata.pagination === false ? false : true;
totalRows.value = data.length;
if (showPaging.value && pagedata.api) {
if (pagedata.api.full_data === false) totalRows.value = pagedata.api.total_rows;
showPaging.value = totalRows.value > perPage;
}
};
const close = function () {
showmodal.value = undefined;
};
const frontendFilter = function (newVal) {
let checkValid = function (name, x, filter) {
if ($empty(x[name])) return false;
else {
let text = "";
filter.map((y, k) => {
text += `${
k > 0 ? (filter[k - 1].operator === "and" ? " &&" : " ||") : ""
} ${$formatNumber(x[name])}
${
y.condition === "=" ? "==" : y.condition === "<>" ? "!==" : y.condition
} ${$formatNumber(y.value)}`;
});
return $calc(text);
}
};
newVal = $copy(newVal);
var data = $copy(pagedata.data);
newVal
.filter((m) => m.select || m.filter)
.map((v) => {
if (v.select) {
data = data.filter(
(x) =>
v.select.findIndex((y) => ($empty(y) ? $empty(x[v.name]) : y === x[v.name])) >
-1
);
} else if (v.filter) {
data = data.filter((x) => checkValid(v.name, x, v.filter));
}
});
let sort = {};
let format = {};
let list = filters.filter((x) => x.sort);
list.map((v) => {
sort[v.name] = v.sort === "az" ? "asc" : "desc";
format[v.name] = v.format;
});
return list.length > 0 ? $multiSort(data, sort, format) : data;
};
const backendFilter = function (newVal) {};
const doFilter = function (newVal, nonset) {
if (currentPage > 1 && nonset !== true) currentPage = 1;
if (pagedata.api.full_data) {
data = frontendFilter(newVal);
pagedata.dataFilter = $copy(data);
store.commit(props.pagename, pagedata);
emit("changedata", newVal);
} else {
if (timer) clearTimeout(timer);
timer = setTimeout(() => backendFilter(newVal), 200);
}
pagedata.filters = newVal;
store.commit(props.pagename, pagedata);
emit("changefilter", newVal ? newVal.length > 0 : false);
};
const doSelect = function (value) {
showmodal.value = undefined;
let field = currentField;
let found = filters.find((v) => v.name === field.name);
if (found) {
!found.select ? (found.select = []) : false;
let idx = found.select.findIndex((x) => x === value);
idx >= 0 ? $remove(found.select, idx) : found.select.push(value);
if (found.select.length === 0) {
idx = filters.findIndex((v) => v.name === field.name);
if (idx >= 0) $remove(filters, idx);
}
} else {
filters.push({
name: field.name,
label: field.label,
select: [value],
format: field.format,
});
}
doFilter(filters);
updateShow();
};
const doubleScroll = function (element) {
var _scrollbar = document.createElement("div");
_scrollbar.appendChild(document.createElement("div"));
_scrollbar.style.overflow = "auto";
_scrollbar.style.overflowY = "hidden";
_scrollbar.firstChild.style.width = element.scrollWidth + "px";
_scrollbar.firstChild.style.height = "1px";
_scrollbar.firstChild.appendChild(document.createTextNode("\xA0"));
var running = false;
_scrollbar.onscroll = function () {
if (running) {
running = false;
return;
}
running = true;
element.scrollLeft = _scrollbar.scrollLeft;
};
element.onscroll = function () {
if (running) {
running = false;
return;
}
running = true;
_scrollbar.scrollLeft = element.scrollLeft;
};
element.parentNode.insertBefore(scrollbar, element);
_scrollbar.scrollLeft = element.scrollLeft;
scrollbar = _scrollbar;
};
const removeFilter = function (i) {
$remove(filters, i);
doFilter(filters);
updateShow();
};
const scrollbarVisible = function () {
let element = this.$refs["container"];
if (!element) return;
let result = element.scrollWidth > element.clientWidth ? true : false;
if (scrollbar) {
element.parentNode.removeChild(scrollbar);
scrollbar = undefined;
}
if (result) doubleScroll(element);
};
const updateData = async function (newVal) {
if (newVal.columns) {
//change attribute
fields = $copy(newVal.columns);
let _fields = fields.filter((v) => v.show);
data.map((v) => {
_fields.map((x) => (v[`${x.name}color`] = getStyle(x, v)));
});
return updateShow();
}
if (newVal.tablesetting) {
tablesetting = newVal.tablesetting;
perPage = $formatNumber(tablesetting.find((v) => v.code == "per-page").detail);
currentPage = 1;
}
tablesetting = $copy(pagedata.tablesetting || gridsetting);
if (tablesetting) {
perPage = pagedata.perPage
? pagedata.perPage
: Number(tablesetting.find((v) => v.code === "per-page").detail);
}
if (newVal.fields) {
fields = $copy(newVal.fields);
} else fields = $copy(pagedata.fields);
if (newVal.data || newVal.fields) {
let copy = $copy(newVal.data || data);
this.data = $calculateData(copy, fields);
let fields = fields.filter((v) => v.show);
data.map((v) => {
fields.map((x) => (v[`${x.name}color`] = getStyle(x, v)));
});
}
if (newVal.filters) filters = $copy(newVal.filters);
else if (pagedata.filters) filters = $copy(pagedata.filters);
if (newVal.data || newVal.fields || newVal.filters) {
let copy = $copy(filters);
filters.map((v, i) => {
let idx = $findIndex(fields, { name: v.name });
let index = $findIndex(copy, { name: v.name });
if (idx < 0 && index >= 0) $delete(copy, index);
else if (idx >= 0 && index >= 0) copy[index].label = fields[idx].label;
});
filters = copy;
doFilter(filters);
}
if (newVal.data || newVal.fields || newVal.filters || newVal.tablesetting) updateShow();
if (newVal.data || newVal.fields) setTimeout(() => scrollbarVisible(), 100);
if (newVal.highlight) setTimeout(() => highlight(newVal.highlight), 50);
};
const doubleClick = function (field, v) {
currentField = field;
doSelect(v[field.name]);
};
var tableStyle = getSettingStyle("table");
setTimeout(() => updateShow(), 200);
</script>
<style scoped>
:deep(.table tbody tr:hover td, .table tbody tr:hover th) {
background-color: hsl(0, 0%, 78%);
color: rgb(0, 0, 0);
}
</style>

View File

@@ -0,0 +1,413 @@
<template>
<TimeOption
v-bind="{ pagename: vpagename, api: api, timeopt: timeopt, filter: optfilter, importdata: props.importdata, newDataAvailable: newDataAvailable, params: vparams }"
ref="timeopt" @option="timeOption" @excel="exportExcel" @add="insert" @manual-refresh="manualRefresh" @refresh-data="refreshData"
@import="openImportModal" class="mb-3" v-if="timeopt"></TimeOption>
<DataTable v-bind="{ pagename: vpagename }" @edit="edit" @insert="insert" @dataevent="dataEvent" v-if="pagedata" />
<Modal @close="showmodal = undefined" v-bind="showmodal" v-if="showmodal" />
</template>
<script setup>
import TimeOption from '~/components/datatable/TimeOption'
import { useStore } from '~/stores/index'
// [FIX] Thêm onActivated, onDeactivated để xử lý KeepAlive
import { ref, watch, onBeforeUnmount, onActivated, onDeactivated } from 'vue'
const emit = defineEmits(['modalevent', 'dataevent', 'dataUpdated'])
const store = useStore()
const props = defineProps({
pagename: String,
api: String,
setting: String,
filter: Object,
params: Object,
data: Object,
modal: Object,
timeopt: Object,
realtime: Object,
importdata: Object
})
const { $copy, $find, $findapi, $getapi, $setpage, $clone, $stripHtml, $snackbar, $dayjs } = useNuxtApp()
const showmodal = ref()
const pagedata = ref()
const newDataAvailable = ref(false)
const pendingNewData = ref(null)
const lastDataHash = ref(null)
const pollingInterval = ref(null)
let vpagename = props.pagename
let vfilter = props.filter ? $copy(props.filter) : undefined
let vparams = props.params ? $copy(props.params) : undefined
let connection = undefined
let optfilter = props.filter || (props.params ? props.params.filter : undefined)
const realtimeConfig = ref({ time: 0, update: "true" })
if (props.realtime) {
realtimeConfig.value = { time: props.realtime.time || 0, update: props.realtime.update }
}
if (vparams?.filter) {
for (const [key, value] of Object.entries(vparams.filter)) {
if (value.toString().indexOf('$') >= 0) {
vparams.filter[key] = store[value.replace('$', '')].id
}
}
}
const generateDataHash = (data) => {
if (!data) return null
try {
const replacer = (key, value) =>
value && typeof value === 'object' && !Array.isArray(value)
? Object.keys(value)
.sort()
.reduce((sorted, key) => {
sorted[key] = value[key];
return sorted;
}, {})
: value;
const stringToHash = JSON.stringify(data, replacer);
return stringToHash.split('').reduce((a, b) => {
a = ((a << 5) - a) + b.charCodeAt(0)
return a & a
}, 0)
} catch (e) {
console.error('Error generating data hash:', e);
return null
}
}
// [FIX] Tách hàm dừng polling ra riêng để tái sử dụng
const stopAutoCheck = () => {
if (pollingInterval.value) {
clearInterval(pollingInterval.value)
pollingInterval.value = null
}
}
const startAutoCheck = () => {
// [FIX] Dừng interval cũ trước khi tạo mới, tránh tạo nhiều interval chồng nhau
stopAutoCheck()
if (realtimeConfig.value.time && realtimeConfig.value.time > 0) {
pollingInterval.value = setInterval(() => checkDataChanges(), realtimeConfig.value.time * 1000)
}
}
const checkDataChanges = async () => {
try {
const connlist = []
const conn1 = $findapi(props.api)
if (vfilter) {
const filter = $copy(conn1.params.filter) || {}
for (const [key, value] of Object.entries(vfilter)) {
filter[key] = value
}
for (const [key, value] of Object.entries(filter)) {
if (value.toString().indexOf('$') >= 0) {
filter[key] = store[value.replace('$', '')].id
}
}
conn1.params.filter = filter
}
if (vparams) conn1.params = $copy(vparams)
delete conn1.params.sort
delete conn1.params.values
conn1.params.summary = 'aggregate'
conn1.params.distinct_values = JSON.stringify({
total_count: { type: 'Count', field: 'id' },
last_updated: { type: 'Max', field: 'update_time' },
last_created: { type: 'Max', field: 'create_time' }
})
connlist.push(conn1)
const rs = await $getapi(connlist)
const obj = $find(rs, { name: props.api })
const newMetadata = obj ? obj.data.rows : {}
const newHash = generateDataHash(newMetadata)
if (lastDataHash.value === null) {
lastDataHash.value = newHash
return
}
if (newHash !== lastDataHash.value) {
lastDataHash.value = newHash
if (realtimeConfig.value.update === "true") {
await loadFullDataAsync()
emit('dataUpdated', { newData: store[vpagename].data, autoUpdate: true, hasChanges: true })
} else {
newDataAvailable.value = true
emit('dataUpdated', { newData: null, autoUpdate: false, hasChanges: true })
}
}
} catch (error) {
console.error('Error checking data:', error)
}
}
const loadFullDataAsync = async () => {
try {
const connlist = []
const conn1 = $findapi(props.api)
if (vfilter) {
const filter = $copy(conn1.params.filter) || {}
for (const [key, value] of Object.entries(vfilter)) {
filter[key] = value
}
for (const [key, value] of Object.entries(filter)) {
if (value.toString().indexOf('$') >= 0) {
filter[key] = store[value.replace('$', '')].id
}
}
conn1.params.filter = filter
}
if (vparams) conn1.params = $copy(vparams)
delete conn1.params.summary
delete conn1.params.distinct_values
connlist.push(conn1)
const rs = await $getapi(connlist)
const obj = $find(rs, { name: props.api })
const newData = obj ? $copy(obj.data.rows) : []
updateDataDisplay(newData)
} catch (error) {
console.error('Error loading full data:', error)
}
}
const openImportModal = () => {
const copy = $copy(props.importdata)
showmodal.value = copy
}
const updateDataDisplay = (newData) => {
const copy = $clone(store[vpagename])
copy.data = newData
copy.update = { data: newData }
store.commit(vpagename, copy)
newDataAvailable.value = false
pendingNewData.value = null
}
const manualRefresh = () => {
if (pendingNewData.value) {
updateDataDisplay(pendingNewData.value)
}
}
const refreshData = async () => {
stopAutoCheck()
newDataAvailable.value = false;
pendingNewData.value = null;
await getApi();
lastDataHash.value = null;
await checkDataChanges();
newDataAvailable.value = false;
startAutoCheck();
}
watch(() => props.realtime, (newVal) => {
if (newVal) {
realtimeConfig.value.time = newVal.time || 0
realtimeConfig.value.update = newVal.update === true
startAutoCheck()
}
}, { deep: true })
onDeactivated(() => {
stopAutoCheck()
})
onActivated(() => {
startAutoCheck()
})
onBeforeUnmount(() => {
stopAutoCheck()
})
const timeOption = (v) => {
if (!v) return getApi()
if (v.filter_or) {
if (vfilter) vfilter = undefined
if (vparams) {
vparams.filter_or = v.filter_or
} else {
const found = $copy($findapi(props.api))
found.params.filter_or = v.filter_or
if (props.filter) {
const filter = $copy(props.filter)
for (const [key, value] of Object.entries(filter)) {
if (value.toString().indexOf('$') >= 0) {
filter[key] = store[value.replace('$', '')].id
}
}
found.params.filter = filter
}
vparams = found.params
}
return getApi()
}
let filter = vfilter ? vfilter : (props.params ? props.params.filter || {} : {})
for (const [key, value] of Object.entries(v.filter)) {
filter[key] = value
}
for (const [key, value] of Object.entries(filter)) {
if (value.toString().indexOf('$') >= 0) {
filter[key] = store[value.replace('$', '')].id
}
}
if (vfilter) {
vfilter = filter
vparams = undefined
} else if (vparams) {
vparams.filter = filter
vparams.filter_or = undefined
}
if (!vfilter && !vparams) vfilter = filter
getApi()
}
const edit = (v) => {
const copy = props.modal ? $copy(props.modal) : {}
const f = copy.vbind ? copy.vbind : { pagename: vpagename, api: props.api, row: v }
f.pagename = vpagename
f.row = v
copy.vbind = f
showmodal.value = copy
}
const insert = () => {
const copy = props.modal ? $copy(props.modal) : {}
const f = copy.vbind ? copy.vbind : { pagename: vpagename, api: props.api }
f.pagename = vpagename
copy.vbind = f
showmodal.value = copy
}
const getApi = async () => {
const connlist = []
let row = props.setting?.id ? $copy(props.setting) : undefined
if (!row) {
const found = $find(store.settings.filter(v => v), props.setting > 0 ? { id: props.setting } : { name: props.setting })
if (found) row = $copy(found)
}
if (!row) {
const conn = $findapi('usersetting')
conn.params.filter = props.setting > 0 ? { id: props.setting } : { name: props.setting }
connlist.push(conn)
}
let data = props.data ? $copy(props.data) : undefined
if (!data) {
const conn1 = $findapi(props.api)
if (vfilter) {
const filter = conn1.params.filter || {}
for (const [key, value] of Object.entries(vfilter)) {
filter[key] = value
}
for (const [key, value] of Object.entries(filter)) {
if (value.toString().indexOf('$') >= 0) {
filter[key] = store[value.replace('$', '')].id
}
}
conn1.params.filter = filter
}
if (vparams) conn1.params = vparams
connection = conn1
connlist.push(conn1)
}
let obj = undefined
if (connlist.length > 0) {
const rs = await $getapi(connlist)
const ele = $find(rs, { name: 'usersetting' })
if (ele) {
row = $find(ele.data.rows, { name: props.setting.name || props.setting })
const copy = $copy(store.settings)
copy.push(row)
store.commit('settings', copy)
}
obj = $find(rs, { name: props.api })
if (obj) data = $copy(obj.data.rows)
}
pagedata.value = $setpage(vpagename, row, obj)
const copy = $clone(pagedata.value)
copy.data = data
copy.update = { data: data }
store.commit(vpagename, copy)
}
const dataEvent = (v, field, data) => {
if (data?.modal) {
const copy = $copy(data.modal)
const f = copy.vbind ? copy.vbind : {}
if (!f.api) f.api = props.api
f.pagename = vpagename
if (!f.row) f.row = v
copy.vbind = f
copy.field = field
showmodal.value = copy
}
emit('modalevent', { name: 'dataevent', data: { row: v, field: field } })
emit('dataevent', v, field)
}
const exportExcel = async () => {
if (!props.api) return
const found = $findapi('exportcsv')
found.params = connection.params
const fields = pagedata.value.fields
.filter(v => (v.show && v.export !== 'no') || v.export === 'yes')
.map(x => ({ name: x.name, label: $stripHtml(x.label) }))
found.params.fields = JSON.stringify(fields)
found.url = connection.url.replace('data/', 'exportcsv/')
const rs = await $getapi([found])
if (rs === 'error') {
$snackbar('Đã xảy ra lỗi. Vui lòng thử lại.')
return
}
const url = window.URL.createObjectURL(new Blob([rs[0].data]))
const link = document.createElement('a')
const fileName = `${$dayjs(new Date()).format('YYYYMMDDhhmmss')}-data.csv`
link.href = url
link.setAttribute('download', fileName)
document.body.appendChild(link)
link.click()
link.remove()
}
if (!props.timeopt) await getApi()
startAutoCheck()
</script>

View File

@@ -0,0 +1,82 @@
<template>
<div>
<p class="fsb-20 mb-5">Điều chỉnh tiêu đề</p>
<div v-for="(v, i) in arr" :key="i" :class="(i>0? 'mt-4' : null)">
<p class="fsb-14">Dòng thứ {{(i+1)}}<span class="has-text-danger"> *</span></p>
<div class="field has-addons mt-1">
<div class="control is-expanded">
<input class="input" type="text" v-model="v.label">
</div>
<div class="control">
<a class="button px-2 is-primary" @click="add()">
<span>
<SvgIcon v-bind="{name: 'add1.png', type: 'white', size: 17}"></SvgIcon></span>
</a>
</div>
<div class="control" @click="remove(i)" v-if="(i>0)">
<a class="button px-2 is-dark">
<span>
<SvgIcon v-bind="{name: 'bin.svg', type: 'white', size: 17}"></SvgIcon>
</span>
</a>
</div>
</div>
<p class="help has-text-danger" v-if="v.error"> {{v.error}} </p>
</div>
<div class="buttons mt-5">
<button class="button is-primary has-text-white" @click="update()">Cập nhật</button>
<button class="button is-dark" @click="$emit('close')">Hủy bỏ</button>
</div>
</div>
</template>
<script>
export default {
props: ['label'],
data() {
return {
arr: []
}
},
created() {
let arr1 = this.label.replace('<div>', '').replace('</div>', '').split("</p>")
arr1.map(v=>{
if(!this.$empty(v)) {
let label = v + '</p>'
label = this.$stripHtml(label)
this.arr.push({label: label})
}
})
},
methods: {
add() {
this.arr.push({label: undefined})
},
remove(i) {
this.$remove(this.arr, i)
},
checkError() {
let error = false
this.arr.map(v=>{
if(this.$empty(v.label)) {
v.error = 'Nội dung không được bỏ trống'
error = true
}
})
if(error) this.arr = this.$copy(this.arr)
return error
},
update() {
if(this.checkError()) return
let label = ''
if(this.arr.length>1) {
this.arr.map((v,i)=>{
label += `<p${i<this.arr.length-1? ' style="border-bottom: 1px solid white;"' : ''}>${v.label.trim()}</p>`
})
label = `<div>${label}</div>`
} else label = this.arr[0].label.trim()
this.$emit('modalevent', {name: 'label', data: label})
this.$emit('close')
}
}
}
</script>

View File

@@ -0,0 +1,88 @@
<template>
<div>
<template v-if="keys.length>0">
<div class="field is-horizontal" v-for="(v,i) in keys" :key="i">
<div class="field-body">
<div class="field is-narrow">
<div class="control">
<input class="input fs-14" type="text" placeholder="" v-model="keys[i]">
</div>
</div>
<div class="field">
<div class="control">
<input class="input fs-14" type="text" placeholder="" v-model="values[i]">
</div>
</div>
<div class="field is-narrow">
<p class="control">
<a @click="addAttr()">
<SvgIcon v-bind="{name: 'add1.png', type: 'gray', size: 18}"></SvgIcon>
</a>
<a class="ml-2" @click="remove(i)">
<SvgIcon v-bind="{name: 'bin1.svg', type: 'gray', size: 18}"></SvgIcon>
</a>
<a class="ml-2" @click="jsonData(v, i)">
<SvgIcon v-bind="{name: 'apps.svg', type: 'gray', size: 18}"></SvgIcon>
</a>
</p>
</div>
</div>
</div>
</template>
<div class="mb-6" v-else>
<button class="button is-primary has-text-white" @click="addAttr()">Thêm thuộc tính</button>
</div>
<div class="buttons mt-5">
<a class="button is-primary has-text-white" @click="update()">Cập nhật</a>
</div>
<Modal @close="comp=undefined" @update="doUpdate"
v-bind="{component: comp, width: '40%', height: '300px', vbind: vbind}" v-if="comp"></Modal>
</div>
</template>
<script>
export default {
props: ['field', 'close'],
data() {
return {
keys: [],
values: [],
comp: undefined,
vbind: undefined,
current: undefined
}
},
created() {
Object.keys(this.field).map(v=>{
this.keys.push(v)
this.values.push(this.field[v])
})
},
methods: {
doUpdate(v) {
this.values[this.current.i] = v
},
jsonData(v, i) {
this.current = {v: v, i: i}
this.vbind = {field: this.$empty(this.values[i]) || typeof this.values[i] === 'string'? {} : this.values[i], close: true}
this.comp = 'datatable/FieldAttribute'
},
addAttr() {
this.keys.push(undefined)
this.values.push(undefined)
},
remove(i) {
this.$remove(this.keys, i)
this.$remove(this.values, i)
},
update() {
let obj = {}
this.keys.map((v,i)=>{
if(!this.$empty(v)) obj[v] = v.indexOf('__in')>0? this.values[i].split(',') : this.values[i]
})
this.$emit('update', obj)
this.$emit('modalevent', {name: 'update', data: obj})
if(this.close) this.$emit('close')
}
}
}
</script>

View File

@@ -0,0 +1,170 @@
<template>
<div>
<div class="field mt-3 mb-1" v-if="field.format==='number'">
<label class="label fs-14">Chọn trường<span class="has-text-danger"> * </span> </label>
<div class="control">
<b-taginput
size="is-small"
v-model="tagsField"
:data="pageData? pageData.fields.filter(v=>v.format==='number') : []"
type="is-dark is-light"
autocomplete
:open-on-focus="true"
field="name"
icon="plus"
placeholder="Chọn trường"
>
<template slot-scope="props">
<span class="mr-3 has-text-danger has-text-weight-bold"> {{props.option.name}}</span>
<span :class="tagsField.find(v=>v.id===props.option.id)? 'has-text-dark' : ''"> {{$stripHtml(props.option.label, 60)}} </span>
</template>
<template slot="empty">
Không trường thỏa mãn
</template>
</b-taginput>
</div>
<p class="help has-text-danger" v-if="errors.find(v=>v.name==='tagsField')"> {{errors.find(v=>v.name==='tagsField').message}} </p>
</div>
<div class="mt-2" v-if="tagsField.length>0">
<a @dblclick="expression = expression? (expression + ' ' + v.name) : v.name"
class="tag is-rounded" v-for="(v,i) in tagsField" :key="i">
<span class="tooltip">
{{ v.name }}
<span class="tooltiptext" style="top: 60%; bottom: unset; min-width: max-content; left: 25px;">{{ $stripHtml(v.label) }}</span>
</span>
</a>
</div>
<div class="field is-horizontal mt-3">
<div class="field-body">
<div class="field" v-if="field.format==='number'">
<label class="label fs-14">Biểu thức dạng Đúng / Sai <span class="has-text-danger"> * </span> </label>
<p class="control is-expanded">
<input class="input is-small" type="text" v-model="expression">
</p>
<p class="help has-text-danger" v-if="errors.find(v=>v.name==='expression')"> {{errors.find(v=>v.name==='expression').message}} </p>
</div>
<div class="field" v-else>
<label class="label"> Chuỗi tự <span class="has-text-danger"> * </span>
</label>
<p class="control">
<input
class="input is-small"
type="text"
placeholder=""
v-model="searchText"
@change="changeStyle()"
/>
</p>
<p
class="help has-text-danger"
v-if="errors.find((v) => v.name === 'searchText')"
>
{{ errors.find((v) => v.name === "searchText").msg }}
</p>
</div>
<div class="field is-narrow" v-if="filterType==='color'">
<label class="label fs-14"> màu <span class="has-text-danger"> * </span> </label>
<p class="control fs-14">
<input type="color" v-model="color" @change="changeStyle()">
</p>
<p class="help has-text-danger" v-if="errors.find(v=>v.name==='color')"> {{errors.find(v=>v.name==='color').message}} </p>
</div>
<div class="field is-narrow" v-else-if="filterType==='size'">
<label class="label fs-14"> Cỡ chữ <span class="has-text-danger"> * </span> </label>
<p class="control fs-14">
<input class="input is-small" type="text" placeholder="Nhập số" v-model="size" @change="changeStyle()">
</p>
<p class="help has-text-danger" v-if="errors.find(v=>v.name==='size')"> {{errors.find(v=>v.name==='size').message}} </p>
</div>
</div>
</div>
</div>
</template>
<script>
export default {
props: ['filterObj', 'filterType', 'pagename', 'field'],
data() {
return {
tagsField: [],
expression: undefined,
form: undefined,
color: undefined,
size: undefined,
errors: [],
searchText: undefined
}
},
created() {
this.color = this.filterObj.color
this.size = this.filterObj.size
this.expression = this.filterObj.expression? this.filterObj.expression : this.field.name
if(this.filterObj.tags) {
this.filterObj.tags.map(v=>{
this.tagsField.push(this.pageData.fields.find(x=>x.name===v))
})
} else if(this.field.format==='number') this.tagsField.push(this.pageData.fields.find(v=>v.name===this.field.name))
},
watch: {
expression: function(newVal) {
if(this.$empty(newVal)) return
else this.changeStyle()
}
},
computed: {
colorscheme: {
get: function() {return this.$store.state.colorscheme},
set: function(val) {this.$store.commit("updateColorScheme", {colorscheme: val})}
},
pageData: {
get: function() {return this.$store.state[this.pagename]},
set: function(val) {this.$store.commit('updateStore', {name: this.pagename, data: val})}
},
colorchoice: {
get: function() {return this.$store.state.colorchoice},
set: function(val) {this.$store.commit("updateColorChoice", {colorchoice: val})}
}
},
methods: {
changeStyle() {
let check = this.field.format==='number'? this.checkExpression() : this.checkCondition()
if(!check) return
var row = this.field.format==='number'? {expression: this.expression, tags: this.tagsField.map(v=>v.name)}
: {keyword: this.searchText, type: 'search'}
this.filterType==='color'? row.color = this.color : row.size = this.size
this.$emit('databack', row)
},
checkCondition() {
this.errors = []
if(this.filterType==='color' && this.$empty(this.color)) this.errors.push({name: 'color', message: 'Chọn màu'})
if(this.filterType==='size' && this.$empty(this.size)) this.errors.push({name: 'size', message: 'Nhập cỡ chữ'})
if(this.$empty(this.searchText)) this.errors.push({name: 'searchText', message: 'Chưa nhập chuỗi kí tự'})
return this.errors.length>0? false : true
},
checkExpression() {
this.errors = []
if(this.filterType==='color' && this.$empty(this.color)) this.errors.push({name: 'color', message: 'Chọn màu'})
if(this.filterType==='size' && this.$empty(this.size)) this.errors.push({name: 'size', message: 'Nhập cỡ chữ'})
let val = this.$copy(this.expression)
let exp = this.$copy(this.expression)
this.tagsField.forEach(v => {
let myRegExp = new RegExp(v.name, 'g')
val = val.replace(myRegExp, Math.random())
exp = exp.replace(myRegExp, "field.formatNumber(row['" + v.name + "'])")
})
try {
let value = this.$calc(val)
if(isNaN(value) || value===Number.POSITIVE_INFINITY || value===Number.NEGATIVE_INFINITY) {
this.errors.push({name: 'expression', message: 'Biểu thức không hợp lệ'})
} else if(!(eval(value)===true || eval(value)===false)) {
this.errors.push({name: 'expression', message: 'Biểu thức không hợp lệ'})
}
}
catch(err) {
this.errors.push({name: 'expression', message: 'Biểu thức không hợp lệ'})
}
return this.errors.length>0? false : true
}
}
}
</script>

View File

@@ -0,0 +1,207 @@
<template>
<div v-if="['bgcolor', 'color', 'textsize'].find((x) => x === sideBar)">
<p class="has-text-right has-text-grey is-italic fs-13">
Màu sắc sẽ hiển thị theo điều kiện Đúng / Sai, lệnh do hệ thống tự sinh
</p>
<div class="tabs is-boxed">
<ul>
<li v-for="(v, i) in tabs" :key="i" :class="tab.code === v.code ? 'is-active' : ''" @click="tab = v">
<a>{{ v.name }}</a>
</li>
</ul>
</div>
</div>
<template v-if="tab.code === 'expression' && ['bgcolor', 'color', 'textsize'].find((x) => x === sideBar)">
<template v-if="radio ? radio.code === 'condition' && sideBar === 'bgcolor' : false">
<div v-for="(v, i) in bgcolorFilter" :key="v.id" class="px-4">
<FilterOption
v-bind="{ filterObj: v, filterType: 'color', pagename: pagename, field: openField }"
:ref="v.id"
@databack="doConditionFilter($event, 'bgcolor', v.id)"
/>
<p class="fs-14 mt-1" :class="currentField.format === 'string' ? 'mb-1' : 'mb-2'">
<a class="has-text-primary mr-5" @click="addCondition(bgcolorFilter)" v-if="bgcolorFilter.length <= 30">
Thêm
</a>
<a class="has-text-danger" @click="removeCondition(bgcolorFilter, i)" v-if="bgcolorFilter.length > 1">
Bớt
</a>
</p>
</div>
</template>
<template v-else-if="radio ? radio.code === 'condition' && sideBar === 'color' : false">
<div v-for="(v, i) in colorFilter" :key="v.id" class="px-4">
<FilterOption
v-bind="{ filterObj: v, filterType: 'color', pagename: pagename, field: openField }"
:ref="v.id"
@databack="doConditionFilter($event, 'color', v.id)"
/>
<p class="fs-14 mt-1" :class="currentField.format === 'string' ? 'mb-1' : 'mb-2'">
<a class="has-text-primary mr-5" @click="addCondition(colorFilter)" v-if="colorFilter.length <= 30"> Thêm </a>
<a class="has-text-danger" @click="removeCondition(colorFilter, i)" v-if="colorFilter.length > 1"> Bớt </a>
</p>
</div>
</template>
<template v-else-if="radio ? radio.code === 'condition' && sideBar === 'textsize' : false">
<div v-for="(v, i) in sizeFilter" :key="v.id" class="px-4">
<FilterOption
v-bind="{ filterObj: v, filterType: 'size', pagename: pagename, field: openField }"
:ref="v.id"
@databack="doConditionFilter($event, 'textsize', v.id)"
/>
<p class="fs-14 mt-1" :class="currentField.format === 'string' ? 'mb-1' : 'mb-2'">
<a class="has-text-primary mr-5" @click="addCondition(sizeFilter)" v-if="sizeFilter.length <= 30"> Thêm </a>
<a class="has-text-danger" @click="removeCondition(sizeFilter, i)" v-if="sizeFilter.length > 1"> Bớt </a>
</p>
</div>
</template>
</template>
<template v-else-if="tab.code === 'script' && ['bgcolor', 'color', 'textsize'].find((x) => x === sideBar)">
<p class="my-4 mx-4">
<a @click="copyContent(script ? script : '')" class="mr-6">
<span class="icon-text">
<SvgIcon class="mr-2" v-bind="{ name: 'copy.svg', type: 'primary', siz: 18 }"></SvgIcon>
<span class="fs-16">Copy</span>
</span>
</a>
<a @click="paste()" class="mr-6">
<span class="icon-text">
<SvgIcon class="mr-2" v-bind="{ name: 'pen1.svg', type: 'primary', siz: 18 }"></SvgIcon>
<span class="fs-16">Paste</span>
</span>
</a>
</p>
<div class="mx-4">
<textarea class="textarea fs-14" rows="8" v-model="script" @change="checkScript()" @dblclick="doCheck"></textarea>
</div>
<p class="mt-5 mx-4">
<span class="icon-text fsb-18">
Replace
<SvgIcon v-bind="{ name: 'right.svg', type: 'dark', size: 22 }"></SvgIcon>
</span>
</p>
<div class="field is-grouped mx-4 mt-4">
<div class="control">
<p class="fsb-14 mb-1">Đoạn text</p>
<input class="input" type="text" placeholder="" v-model="source" />
</div>
<div class="control">
<p class="fsb-14 mb-1">Thay bằng</p>
<input class="input" type="text" placeholder="" v-model="target" />
</div>
<div class="control pl-5">
<button class="button is-primary is-rounded is-outlined mt-5" @click="replace()">Replace</button>
</div>
</div>
<p class="mt-5 pt-2 mx-4">
<span class="icon-text fsb-18">
Thay đổi màu
<SvgIcon v-bind="{ name: 'right.svg', type: 'dark', size: 22 }"></SvgIcon>
</span>
</p>
<p class="mx-4 mt-4"><button class="button is-primary is-rounded" @click="changeScript()">Cập nhật</button></p>
</template>
<TableOption v-bind="{ pagename: pagename }" v-else-if="sideBar === 'option'"> </TableOption>
<CreateTemplate v-else-if="sideBar === 'template'" v-bind="{ pagename: pagename, field: openField }">
</CreateTemplate>
</template>
<script setup>
// FilterOption: () => import("@/components/datatable/FilterOption"),
// TableOption: () => import("@/components/datatable/TableOption"),
//CreateTemplate: () => import("@/components/datatable/CreateTemplate")
import CreateTemplate from "~/components/datatable/CreateTemplate";
const { $id, $copy, $empty, $stripHtml } = useNuxtApp();
var props = defineProps({
event: Object,
currentField: Object,
pagename: String,
});
var currentField = props.currentField;
var event = props.event;
var openField = {};
var bgcolorFilter = [];
var colorFilter = [];
var sizeFilter = [];
var sideBar = undefined;
var script = undefined;
var radio = undefined;
var tabs = [
{ code: "expression", name: "Biểu thức" },
{ code: "script", name: "Mã lệnh" },
];
var tab = { code: "expression", name: "Biểu thức" };
var source = undefined;
var target = $copy(currentField.name);
const initData = function () {
openField = event.field;
sideBar = event.name;
script = event.script;
radio = event.radio;
let field = event.field;
bgcolorFilter = [{ id: $id() }];
if (field.bgcolor) {
if (Array.isArray(field.bgcolor)) bgcolorFilter = $copy(field.bgcolor);
}
colorFilter = [{ id: $id() }];
if (field.color) {
if (Array.isArray(field.color)) colorFilter = $copy(field.color);
}
sizeFilter = [{ id: $id() }];
if (field.textsize) {
if (Array.isArray(field.textsize)) sizeFilter = field.textsize;
}
};
const doCheck = function () {
let text = window.getSelection().toString();
if ($empty(text)) return;
source = text;
};
const replace = function () {
if ($empty(script)) return;
script = script.replaceAll(source, target);
};
const paste = async function () {
script = await navigator.clipboard.readText();
};
const addCondition = function (arr) {
arr.push({ id: $id() });
};
const removeCondition = function (arr, i) {
$delete(arr, i);
};
const copyContent = function (value) {
$copyToClipboard(value);
};
const checkScript = function () {
if ($empty(script)) return;
try {
JSON.parse(script);
} catch (e) {
return false;
}
return true;
};
const changeScript = function () {
if (!checkScript()) return;
let copy = $copy(openField);
copy[sideBar] = JSON.parse(script);
$emit("modalevent", { name: "updatefields", data: copy });
};
const doConditionFilter = function (v, type, id) {
v.id = id;
let copy = $copy(currentField);
if (copy[type] ? Array.isArray(copy[type]) : false) {
let idx = copy[type].findIndex((x) => x.id === id);
idx >= 0 ? (copy[type][idx] = v) : copy[type].push(v);
} else copy[type] = [v];
$emit("modalevent", { name: "updatefields", data: copy });
};
initData();
console.log(sideBar);
</script>

View File

@@ -0,0 +1,161 @@
<template>
<div class="mb-4" v-if="currentsetting ? currentsetting.user === login.id : false">
<p class="fs-16 has-text-findata">
Đang mở: <b>{{ $stripHtml(currentsetting.name, 40) }}</b>
</p>
</div>
<div class="field">
<label class="label fs-14">Chọn chế độ lưu <span class="has-text-danger"> * </span></label>
<div class="control is-expanded fs-14">
<a class="mr-5" v-if="isOverwrite()" @click="changeType('overwrite')">
<span class="icon-text">
<SvgIcon
v-bind="{
name: radioSave === 'overwrite' ? 'radio-checked.svg' : 'radio-unchecked.svg',
type: 'gray',
size: 22,
}"
></SvgIcon>
Ghi đè
</span>
</a>
<a @click="changeType('new')">
<span class="icon-text">
<SvgIcon
v-bind="{ name: radioSave === 'new' ? 'radio-checked.svg' : 'radio-unchecked.svg', type: 'gray', size: 22 }"
></SvgIcon>
Tạo mới
</span>
</a>
</div>
</div>
<template v-if="radioSave === 'new'">
<div class="field mt-4 px-0 mx-0">
<label class="label fs-14">Tên thiết lập <span class="has-text-danger"> * </span></label>
<div class="control">
<input class="input" type="text" placeholder="" v-model="name" ref="name" v-on:keyup.enter="saveSetting" />
</div>
<div class="help has-text-danger" v-if="errors.find((v) => v.name === 'name')">
{{ errors.find((v) => v.name === "name").msg }}
</div>
</div>
<div class="field mt-4 px-0 mx-0">
<label class="label fs-14"> Mô tả </label>
<p class="control is-expanded">
<textarea class="textarea" rows="4" v-model="note"></textarea>
</p>
</div>
<!--
<div class="field mt-4 px-0 mx-0">
<label class="label fs-14">Loại thiết lập <span class="has-text-danger"> * </span>
</label>
<div class="control is-expanded fs-14">
<span class="mr-4" v-for="(v,i) in $filter(store.settingtype, {code: ['private', 'public']})">
<a class="icon-text" @click="changeOption(v)">
<SvgIcon v-bind="{name: `radio-${radioOption===v.code? '' : 'un'}checked.svg`, type: radioOption===v.code? 'primary' : 'gray', size: 22}"></SvgIcon>
</a>
{{v.name}}
</span>
</div>
</div>-->
</template>
<div class="field mt-5 px-0 mx-0">
<label class="label fs-14" v-if="status !== undefined" :class="status ? 'has-text-primary' : 'has-text-danger'">
{{ status ? "Lưu thiết lập thành công." : "Lỗi. Lưu thiết lập thất bại." }}
</label>
<p class="control is-expanded">
<a class="button is-primary has-text-white" @click="saveSetting()">Lưu lại</a>
</p>
</div>
</template>
<script setup>
import { ref } from "vue";
import { useStore } from "@/stores/index";
const emit = defineEmits([]);
const store = useStore();
var props = defineProps({
pagename: String,
classify: String,
option: String,
data: Object,
focus: Boolean,
});
const { $empty, $copy, $filter, $stripHtml, $updateapi, $insertapi, $findIndex, $snackbar } = useNuxtApp();
var pagename = props.pagename;
var radioOption = ref();
var login = { id: 1 };
var errors = [];
var radioType = undefined;
var radioDefault = 0;
var radioSave = ref("new");
var note = undefined;
var status = undefined;
var name = undefined;
var currentsetting = undefined;
var pagedata = store[props.pagename];
async function saveSetting() {
errors = [];
let detail = pagename ? { fields: pagedata.fields } : {};
if (pagename) {
let element = pagedata.tablesetting || {};
if (element !== store.originsetting) detail.tablesetting = element;
if (pagedata.filters ? pagedata.filters.length > 0 : false) {
detail.filters = pagedata.filters;
}
}
if (props.option) detail.option = props.option;
if (props.data) detail.data = props.data;
let data = {
user: login.id,
name: name,
detail: detail,
note: note,
type: radioType.id,
classify: props.classify ? props.classify : store.settingclass.find((v) => v.code === "data-field").id,
default: radioDefault,
update_time: new Date(),
};
let result;
if (radioSave.value === "new") {
if ($empty(name)) {
return errors.push({ name: "name", msg: "Tên thiết lập không được bỏ trống" });
}
result = await $insertapi("usersetting", data);
} else {
let copy = $copy(currentsetting);
copy.detail = detail;
copy.update_time = new Date();
result = await $updateapi("usersetting", copy);
}
if (radioSave.value === "new") {
emit("modalevent", { name: "opensetting", data: result });
} else {
let idx = $findIndex(store.settings, { id: result.id });
if (idx >= 0) {
let copy = $copy(store.settings);
copy[idx] = result;
store.commit("settings", copy);
}
$snackbar("Lưu thiết lập thành công");
emit("modalevent", { name: "updatesetting", data: result });
emit("close");
}
}
function isOverwrite() {
return true;
}
function changeType(value) {
radioSave.value = value;
}
function changeOption(v) {
radioOption.value = v.code;
}
function initData() {
radioType = store.settingtype.find((v) => v.code === "private");
if (props.pagename) currentsetting = $copy(pagedata.setting ? pagedata.setting : undefined);
if (!currentsetting) radioSave.value = "new";
else if (currentsetting.user !== login.id) radioSave.value = "new";
else radioSave.value = "overwrite";
}
initData();
</script>

View File

@@ -0,0 +1,159 @@
<template>
<div class="tabs">
<ul>
<li :class="`${v.code === tab ? 'is-active has-text-weight-bold fs-18' : 'fs-18'}`" v-for="v in tabs">
<a @click="changeTab(v)">{{ v.name }}</a>
</li>
</ul>
</div>
<template v-if="tab === 'datatype'">
<Caption class="mb-3" v-bind="{ title: 'Kiểu dữ liệu (type)', type: 'has-text-warning' }"></Caption>
<div class="py-1 border-bottom is-clickable" v-for="x in current.fields">
{{ x.name }}
<span class="ml-6 has-text-grey">{{ x.type }}</span>
<a class="ml-6 has-text-primary" v-if="x.model" @click="openModel(x)">{{ x.model }}</a>
</div>
</template>
<template v-else>
<div class="columns mx-0 mb-0 pb-0">
<div class="column is-7">
<Caption class="mb-2" v-bind="{ title: 'Values', type: 'has-text-warning' }"></Caption>
<input class="input" rows="1" v-model="values" />
</div>
<div class="column is-4s">
<Caption class="mb-2" v-bind="{ title: 'Filter', type: 'has-text-warning' }"></Caption>
<input class="input" rows="1" v-model="filter" />
</div>
<div class="column is-1">
<Caption class="mb-2" v-bind="{ title: 'Load', type: 'has-text-warning' }"></Caption>
<div>
<button class="button is-primary has-text-white" @click="loadData()">Load</button>
</div>
</div>
</div>
<Caption class="mb-2" v-bind="{ title: 'Query', type: 'has-text-warning' }"></Caption>
<div class="mb-4">
{{ query }}
<a class="has-text-primary ml-5" @click="copy()">copy</a>
<p>
{{ apiUrl }}
<a class="has-text-primary ml-5" @click="$copyToClipboard(apiUrl)">copy</a>
<a class="has-text-primary ml-5" target="_blank" :href="apiUrl">open</a>
</p>
</div>
<div>
<Caption class="mb-2" v-bind="{ title: 'Data', type: 'has-text-warning' }"></Caption>
<DataTable v-bind="{ pagename: pagename }" v-if="pagedata" />
</div>
</template>
<Modal @close="showmodal = undefined" v-bind="showmodal" v-if="showmodal"></Modal>
</template>
<script setup>
import { useStore } from "@/stores/index";
var props = defineProps({
data: Array,
info: Object,
});
const { $getdata, $getapi, $createField, $clone, $getpage, $empty, $copyToClipboard, $find } = useNuxtApp();
const store = useStore();
var pagename = "pagedata99";
var pagedata = ref();
pagedata.value = $getpage();
store.commit(pagename, pagedata);
let list = ["LogEntry", "Permission", "ContentType", "Session", "Group"];
var current = ref({ fields: [] });
var tabs = [
{ code: "datatype", name: "Kiểu dữ liệu" },
{ code: "table", name: "Dữ liệu" },
];
var tab = ref("datatype");
var datatable = ref();
var query = ref();
var values, filter;
var apiUrl = ref();
var showmodal = ref();
var data = props.data;
current.value = props.info;
function changeMenu(v) {
values = undefined;
filter = undefined;
current.value = v;
if (tab.value === "table") loadData();
}
async function changeTab(v) {
tab.value = v.code;
if (v.code === "table") loadData();
}
async function loadData() {
let vfilter = filter ? filter.trim() : undefined;
if (vfilter) {
try {
vfilter = JSON.parse(vfilter);
} catch (error) {
alert("Cấu trúc filter có lỗi");
vfilter = undefined;
}
}
let params = { values: values ? values.trim() : undefined, filter: filter };
let modelName = current.value.model;
let found = {
name: modelName.toLowerCase().replace("_", ""),
url: `data/${modelName}/`,
url_detail: `data-detail/${modelName}/`,
params: params,
};
query.value = $clone(found);
let rs = await $getapi([found]);
if (rs === "error") return alert("Đã xảy ra lỗi, hãy xem lại câu lệnh.");
datatable.value = rs[0].data.rows;
showData();
// api query
const baseUrl = "https://api.y99.vn/" + `${query.value.url}`;
apiUrl.value = baseUrl;
let vparams = !$empty(values) ? { values: values } : null;
if (!$empty(filter)) {
vparams = vparams ? { values: values, filter: filter } : { filter: filter };
}
if (vparams) {
let url = new URL(baseUrl);
let searchParams = new URLSearchParams(vparams);
url.search = searchParams.toString();
apiUrl.value = baseUrl + url.search;
}
}
function showData() {
let arr = [];
if (!$empty(values)) {
let arr1 = values.trim().split(",");
arr1.map((v) => {
let val = v.trim();
let field = $createField(val, val, "string", true);
arr.push(field);
});
} else {
current.value.fields.map((v) => {
let field = $createField(v.name, v.name, "string", true);
arr.push(field);
});
}
let clone = $clone(pagedata.value);
clone.fields = arr;
clone.data = datatable.value;
pagedata.value = undefined;
setTimeout(() => (pagedata.value = clone));
}
function copy() {
$copyToClipboard(JSON.stringify(query.value));
}
function openModel(x) {
showmodal.value = {
component: "datatable/ModelInfo",
title: x.model,
width: "70%",
height: "600px",
vbind: { data: data, info: $find(data, { model: x.model }) },
};
}
</script>
s

View File

@@ -0,0 +1,334 @@
<template>
<div class="tabs is-boxed">
<ul>
<li :class="selectType.code===v.code? 'is-active fs-16' : 'fs-16'" v-for="v in fieldType">
<a @click="selectType = v"><span>{{ v.name }}</span></a>
</li>
</ul>
</div>
<template v-if="selectType.code==='formula'">
<b-radio :class="i===1? 'ml-5' : null" v-model="choice" v-for="(v,i) in choices" :key="i"
:native-value="v.code">
<span :class="v.code===choice? 'fsb-16' : 'fs-16'">{{v.name}}</span>
</b-radio>
<div class="has-background-light mt-3 px-3 py-3">
<div class="tags are-medium mb-0" v-if="choice==='function'">
<span :class="`tag ${func===v.code? 'is-primary' : 'is-dark'} is-rounded is-clickable`"
v-for="(v,i) in funcs" :key="i" @click="changeFunc(v)" @dblclick="addFunc(v)">{{v.name}}</span>
</div>
<template v-else>
<div class="field px-0 mx-0">
<label class="label fs-14">Chọn trường<span class="has-text-danger"> *</span> </label>
<div class="control">
<!--<b-taginput
size="is-small"
v-model="tags"
:data="fields.filter(v=>v.format==='number')"
type="is-dark is-light"
autocomplete
:open-on-focus="true"
field="caption"
icon="plus"
placeholder="Chọn trường"
>
<template slot-scope="props">
<span class="mr-3 has-text-danger">{{props.option.name}}</span>
<span :class="tags.find(v=>v.id===props.option.id)? 'has-text-dark' : ''">{{$stripHtml(props.option.label,50)}}</span>
</template>
<template slot="empty">
Không trường thỏa mãn
</template>
</b-taginput>-->
</div>
<p class="help has-text-danger" v-if="errors.find(v=>v.name==='tags')"> {{errors.find(v=>v.name==='tags').message}} </p>
</div>
<div class="field mt-3" v-if="tags.length>0">
<p class="help is-primary mb-1">Click đúp vào để thêm vào công thức tính.</p>
<div class="tags mb-2">
<span @dblclick="formula = formula? (formula + ' ' + v.name) : v.name" class="tag is-dark is-rounded is-clickable"
v-for="v in tags">
{{v.name}}
</span>
</div>
<div class="tags">
<span v-for="(v,i) in operator" :key="i">
<span @dblclick="addOperator(v)" class="tag is-primary is-rounded is-clickable mr-4">
<span class="fs-16">{{v.code}}</span>
</span>
</span>
</div>
</div>
</template>
<div class="field mt-3 px-0 mx-0">
<label class="label fs-14">Công thức tính <span class="has-text-danger"> * </span> </label>
<p class="control">
<textarea class="textarea" rows="3" type="text" :placeholder="placeholder" v-model="formula"> </textarea>
</p>
<p class="help has-text-danger" v-if="errors.find(v=>v.name==='formula')"> {{errors.find(v=>v.name==='formula').message}} </p>
</div>
</div>
<div class="field is-horizontal mt-3 px-0 mx-0">
<div class="field-body">
<div class="field">
<label class="label fs-14">Hiển thị theo <span class="has-text-danger"> * </span> </label>
<div class="control">
<b-autocomplete
size="is-small"
icon-right="magnify"
:value="selectUnit? selectUnit.name : ''"
placeholder=""
:keep-first=true
:open-on-focus=true
:data="moneyunit"
field="name"
@select="option => selectUnit = option">
</b-autocomplete>
</div>
</div>
<div class="field">
<label class="label fs-14">Phần thập phân</label>
<div class="control">
<input class="input is-small" type="text" placeholder="" v-model="decimal">
</div>
</div>
</div>
</div>
</template>
<div class="field px-0 mx-0">
<label class="label">Tên trường <span class="has-text-danger"> * </span> </label>
<p class="control">
<input class="input" type="text" placeholder="Tên trường phải là duy nhất" v-model="name"
:readonly="selectType? selectType.code==='formula': false">
</p>
<p class="help has-text-danger" v-if="errors.find(v=>v.name==='name')"> {{errors.find(v=>v.name==='name').message}} </p>
<p class="help has-text-primary" v-else> Tên trường do hệ thống tự sinh.</p>
</div>
<div class="mt-5">
<label class="label"> tả<span class="has-text-danger"> *</span></label>
<div class="field has-addons">
<div class="control is-expanded" >
<input
class="input"
type="text"
v-model="label"
/>
</div>
<div class="control">
<button class="button" @click="editLabel()">
<span><SvgIcon v-bind="{name: 'pen.svg', type: 'dark', size: 17}"></SvgIcon></span>
</button>
</div>
</div>
<p class="help has-text-danger" v-if="errors.find(v=>v.name==='label')"> {{errors.find(v=>v.name==='label').message}} </p>
</div>
<div class="field mt-5" v-if="selectType.code==='empty'">
<label class="label"
>Kiểu dữ liệu
<span class="has-text-danger"> * </span>
</label>
<div class="control fs-14">
<span class="mr-4" v-for="(v,i) in datatype">
<a class="icon-text" @click="changeType(v)">
<SvgIcon v-bind="{name: `radio-${radioType.code===v.code? '' : 'un'}checked.svg`, type: 'gray', size: 22}"></SvgIcon>
</a>
{{v.name}}
</span>
</div>
</div>
<div class="field mt-5">
<p class="control">
<a class="button is-primary has-text-white"
@click="selectType.code==='formula'? createField() : createEmptyField()">Tạo cột</a>
</p>
</div>
<Modal v-bind="showmodal" v-if="showmodal" @label="changeLabel" @close="close"></Modal>
</template>
<script setup>
import { useStore } from '@/stores/index'
import ScrollBox from '~/components/datatable/ScrollBox'
const emit = defineEmits(['modalevent'])
const store = useStore()
const { $id, $copy, $clone, $empty, $stripHtml, $createField, $calc, $isNumber } = useNuxtApp()
var props = defineProps({
pagename: String,
field: Object,
filters: Object,
filterData: Object,
width: String
})
const moneyunit = store.moneyunit
const datatype = store.datatype
var showmodal = ref()
var pagedata = store[props.pagename]
var selectUnit = moneyunit.find(v=>v.code==='one')
var data = []
var current = 1
var filterData = []
var loading = false
var fieldType = [{code: 'formula', name: 'Tạo công thức'}, {code: 'empty', name: 'Tạo cột rỗng'}]
var errors = []
var tags = []
var formula = undefined
var name = `f${$id().toLocaleLowerCase()}`
var label = undefined
var errors = []
var selectType = fieldType.find(v=>v.code==='empty')
var radioType = ref(datatype.find(v=>v.code==='string'))
var fields = []
var options = undefined
var columns = $copy(pagedata.fields.filter(v=>v.format==='number'))
var decimal = undefined
var choices = [{code: 'column', name: 'Dùng cột dữ liệu'}, {code: 'function', name: 'Dùng hàm số'}]
var choice = 'column'
var funcs = [{code: 'sum', name: 'Sum'}, {code: 'max', name: 'Max'}, {code: 'min', name: 'Min'}, {code: 'avg', name: 'Avg'}]
var func = 'sum'
var placeholder = 'Minh hoạ công thức: f10001 + f10002'
var args = undefined
var operator = [{code: '+', name: 'Cộng'}, {code: '-', name: 'Trừ'}, {code: '*', name: 'Nhân'}, {code: '/', name: 'Chia'}, {code: '>', name: 'Lớn hơn'},
{code: '>=', name: 'Lớn hơn hoặc bằng'}, {code: '<', name: 'Nhỏ hơn'}, {code: '<=', name: 'Nhỏ hơn hoặc bằng'}, {code: '==', name: 'Bằng'},
{code: '&&', name: 'Và'}, {code: '||', name: 'Hoặc'}, {code: 'iif', name: 'Điều kiện rẽ nhánh'}]
function editLabel() {
if($empty(label)) return
showmodal.value = {component: 'datatable/EditLabel', width: '500px', height: '300px', vbind: {label: label}}
}
function close() {
showmodal.value = null
}
function changeLabel(evt) {
label = evt
showmodal.value = null
}
function changeType(v) {
radioType.value = v
}
function addFunc(v) {
formula = (formula? formula + ' ' : '') + v.name + '(C0: C2)'
}
function addOperator(v) {
let text = v.code==='iif'? 'a>b? c : d' : v.code
formula = `${formula || ''} ${text}`
}
function changeFunc(v) {
placeholder = `${v.name}(C0:C2) hoặc ${v.name}(C0,C1,C2). C là viết tắt của cột dữ liệu, số thứ tự của cột bắt đầu từ 0`
func = v.code
}
function getFields() {
fields = pagedata? $copy(pagedata.fields) : []
fields.map(v=>v.caption = (v.label? v.label.indexOf('<')>=0 : false)? v.name : v.label)
}
function checkFunc() {
let error = false
let val = formula.trim().replaceAll(' ', '')
if(val.toLowerCase().indexOf(func)<0) error = true
let start = val.toLowerCase().indexOf('(')
let end = val.toLowerCase().indexOf(')')
if( start<0 || end<0) error = true
let content = val.substring(start+1, end)
if($empty(content)) error = true
let content1 = content.replaceAll(':', ',')
let arr = content1.split(',')
arr.map(v=>{
let arr1 = v.toLowerCase().split('c')
if(arr1.length!==2) error = true
else if(!$isNumber(arr1[1])) error = true
})
return error? 'error' : content
}
function checkValid() {
errors = []
if(tags.length===0 && choice==='column') {
errors.push({name: 'tags', message: 'Chưa chọn trường xây dựng công thức.'})
}
if(!$empty(formula)? $empty(formula.trim()) : true) {
errors.push({name: 'formula', message: 'Công thức không được bỏ trống.'})
}
if(!$empty(label)? $empty(label.trim()) : true )
errors.push({name: 'label', message: 'Mô tả không được bỏ trống.'})
else if(pagedata.fields.find(v=>v.label.toLowerCase()===label.toLowerCase())) {
errors.push({name: 'label', message: 'Mô tả bị trùng. Hãy đặt mô tả khác.'})
}
if(errors.length>0) return false
//check formula in case use column
if(choice==='column') {
let val = $copy(formula)
tags.forEach(v => {
let myRegExp = new RegExp(v.name, 'g')
val = val.replace(myRegExp, Math.random())
})
try {
let value = $calc(val)
if(isNaN(value) || value===Number.POSITIVE_INFINITY || value===Number.NEGATIVE_INFINITY) {
errors.push({name: 'formula', message: 'Công thức không hợp lệ'})
}
}
catch(err) {
console.log(err)
errors.push({name: 'formula', message: 'Công thức không hợp lệ'})
}
} else {
if(checkFunc()==='error') errors.push({name: 'formula', message: `Hàm ${func.toUpperCase()} không hợp lệ`})
}
return errors.length>0? false : true
}
function createField() {
if(!checkValid()) return
let field = $createField(name.trim(), label.trim(), 'number', true)
field.formula = formula.trim().replaceAll(' ', '')
if(choice==='function') {
field.func = func
field.vals = checkFunc()
} else field.tags = tags.map(v=>v.name)
field.level = Math.max(...pagedata.fields.map(v=>v.level? v.level : 0)) + 1
field.unit = selectUnit.detail
field.decimal = decimal
field.disable = 'search,value'
let copy = $copy(pagedata)
copy.fields.push(field)
store.commit(props.pagename, copy)
emit('newfield', field)
tags = []
formula = undefined
label = undefined
name = `f${$id()}`
emit('close')
}
function createEmptyField() {
errors = []
if(!$empty(name)? $empty(name.trim()) : true )
errors.push({name: 'name', message: 'Tên không được bỏ trống.'})
else if(pagedata.fields.find(v=>v.name.toLowerCase()===name.toLowerCase())) {
errors.push({name: 'name', message: 'Tên trường bị trùng. Hãy đặt tên khác.'})
}
if(!$empty(label)? $empty(label.trim()) : true )
errors.push({name: 'label', message: 'Mô tả không được bỏ trống.'})
else if(pagedata.fields.find(v=>v.label.toLowerCase()===label.toLowerCase())) {
errors.push({name: 'label', message: 'Mô tả bị trùng. Hãy đặt mô tả khác.'})
}
if(errors.length>0) return
let field = $createField(name.trim(), label.trim(), radioType.value.code, true)
if(selectType.code==='chart') field = createChartField()
let copy = $clone(pagedata)
copy.fields.push(field)
copy.update = {fields: copy.fields}
store.commit(props.pagename, copy)
//pagedata = copy
emit('newfield', field)
label = undefined
name = `f${$id()}`
emit('close')
}
function createChartField() {
let array = pagedata.fields.filter(v=>v.format==='number' && v.show)
if(args) array = $copy(args)
let text = ''
array.map((v,i)=>text += `'${v.name}${i<array.length-1? "'," : "'"}`)
let label = ''
array.map((v,i)=>label += `'${$stripHtml(v.label)}${i<array.length-1? "'," : "'"}`)
let field = $createField(name.trim(), label.trim(), radioType.value.code, true)
field.chart = 'yes'
field.template = `<TrendingChart class="is-clickable" v-bind="{row: row, fields: [${text}], labels: [${label}], width: '80', height: '26', 'header': ['stock_code', 'name']}"/>`
return field
}
//============
getFields()
</script>

View File

@@ -0,0 +1,64 @@
<template>
<nav class="pagination mx-0" role="navigation" aria-label="pagination">
<ul class="pagination-list" v-if="pageInfo">
<li v-for="v in pageInfo">
<a v-if="currentPage===v" class="pagination-link is-current has-background-primary has-text-white" :aria-label="`Page ${v}`" aria-current="page">{{ v }}</a>
<a v-else href="#" class="pagination-link" :aria-label="`Goto page ${v}`" @click="changePage(v)">{{ v }}</a>
</li>
<a @click="previous()" class="pagination-previous ml-5">
<SvgIcon v-bind="{name: 'left1.svg', type: 'dark', size: 20, alt: 'Tìm kiếm'}"></SvgIcon>
</a>
<a @click="next()" class="pagination-next">
<SvgIcon v-bind="{name: 'right.svg', type: 'dark', size: 20, alt: 'Tìm kiếm'}"></SvgIcon>
</a>
</ul>
</nav>
</template>
<script setup>
const emit = defineEmits(['changepage'])
var props = defineProps({
data: Array,
perPage: Number
})
var currentPage = 1
var totalRows = props.data.length
var lastPage = parseInt(totalRows / props.perPage)
if(lastPage*props.perPage<totalRows) lastPage += 1
var pageInfo = ref()
function pages(current_page, last_page, onSides = 2) {
// pages
let pages = [];
// Loop through
for (let i = 1; i <= last_page; i++) {
// Define offset
let offset = (i == 1 || last_page) ? onSides + 1 : onSides;
// If added
if (i == 1 || (current_page - offset <= i && current_page + offset >= i) ||
i == current_page || i == last_page) {
pages.push(i);
} else if (i == current_page - (offset + 1) || i == current_page + (offset + 1)) {
pages.push('...');
}
}
return pages;
}
function changePage(page) {
if(page==='...') return
currentPage = page
pageInfo.value = pages(page, lastPage, 2)
emit('changepage', page)
}
pageInfo.value = pages(1, lastPage, 2)
watch(() => props.data, (newVal, oldVal) => {
totalRows = props.data.length
lastPage = parseInt(totalRows / props.perPage)
if(lastPage*props.perPage<totalRows) lastPage += 1
pageInfo.value = pages(1, lastPage, 2)
})
function previous() {
if(currentPage>1) changePage(currentPage-1)
}
function next() {
if(currentPage<lastPage) changePage(currentPage+1)
}
</script>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,117 @@
<template>
<div class="px-2" :style="`max-height: ${maxheight}; overflow-y: auto;`">
<div
v-for="(v, i) in rows" :key="i"
:class="[
'field is-grouped py-1 my-0',
i !== rows.length - 1 && 'border-bottom'
]"
>
<p class="control is-expanded py-0 fs-14 hyperlink" @click="doClick(v,i)">
{{ $stripHtml(v[name] || v.fullname || v.code || 'n/a', 75) }}
<span class="icon has-text-primary" v-if="checked[i] && notick!==true">
<SvgIcon v-bind="{name: 'tick.svg', type: 'primary', size: 15}"></SvgIcon>
</span>
</p>
<p class="control py-0" v-if="show">
<span class="icon-text has-text-grey mr-2 fs-13" v-if="show.author">
<SvgIcon v-bind="{name: 'user.svg', type: 'gray', size: 15}"></SvgIcon>
<span>{{ v[show.author] }}</span>
</span>
<span class="icon-text has-text-grey mr-2 fs-13" v-if="show.view">
<SvgIcon v-bind="{name: 'view.svg', type: 'gray', size: 15}"></SvgIcon>
<span>{{ v[show.view] }}</span>
</span>
<span class="fs-13 has-text-grey" v-if="show.time">{{$dayjs(v['create_time']).fromNow(true)}}</span>
<span class="tooltip">
<a class="icon ml-1" v-if="show.link" @click="doClick(v,i, 'newtab')">
<SvgIcon v-bind="{name: 'opennew.svg', type: 'gray', size: 15}"></SvgIcon>
</a>
<span class="tooltiptext">Mở trong tab mớ</span>
</span>
<span class="tooltip" v-if="show.rename">
<a class="icon ml-1" @click="$emit('rename', v, i)">
<SvgIcon v-bind="{name: 'pen1.svg', type: 'gray', size: 15}"></SvgIcon>
</a>
<span class="tooltiptext">Đổi tên</span>
</span>
<span class="tooltip" v-if="show.rename">
<a class="icon has-text-danger ml-1" @click="$emit('remove', v, i)">
<SvgIcon v-bind="{name: 'bin1.svg', type: 'gray', size: 15}"></SvgIcon>
</a>
<span class="tooltiptext">Xóa</span>
</span>
</p>
</div>
</div>
</template>
<script>
export default {
props: ['data', 'name', 'maxheight', 'perpage', 'sort', 'selects', 'keyval', 'show', 'notick'],
data() {
return {
currentPage: 1,
total: this.data.length,
rows: this.data.slice(0, this.perpage),
selected: [],
checked: {},
time: undefined,
array: []
}
},
created() {
this.getdata()
},
watch: {
data: function(newVal) {
this.getdata()
},
selects: function(newVal) {
this.getSelect()
}
},
methods: {
getdata() {
this.currentPage = 1
this.array = this.$copy(this.data)
if(this.sort!==false) {
let f = {}
let showtime = this.show? this.show.time : false
showtime? f['create_time'] = 'desc' : f[this.name] = 'asc'
this.$multiSort(this.array, f)
}
this.rows = this.array.slice(0, this.perpage)
this.getSelect()
},
getSelect() {
if(!this.selects) return
this.selected = []
this.checked = {}
this.selects.map(v=>{
let idx = this.rows.findIndex(x=>x[this.keyval? this.keyval : this.name]===v)
if(idx>=0) {
this.selected.push(this.rows[idx])
this.checked[idx] = true
}
})
},
doClick(v, i, type) {
this.checked[i] = this.checked[i]? false : true
this.checked = this.$copy(this.checked)
let idx = this.selected.findIndex(x=>x.id===v.id)
idx>=0? this.$remove(this.selected) : this.selected.push(v)
this.$emit('selected', v, type)
},
handleScroll(e) {
const bottom = e.target.scrollHeight - e.target.scrollTop -5 < e.target.clientHeight
if (bottom) {
if(this.total? this.total>this.rows.length : true) {
this.currentPage +=1
let arr = this.array.filter((ele,index) => (index>=(this.currentPage-1)*this.perpage && index<this.currentPage*this.perpage))
this.rows = this.rows.concat(arr)
}
}
}
}
}
</script>

View File

@@ -0,0 +1,99 @@
<template>
<table class="table">
<thead>
<tr class="fs-14">
<th>#</th>
<th>Tên trường</th>
<th>Tên cột</th>
<th>...</th>
</tr>
</thead>
<tbody>
<tr class="fs-14" v-for="(v, i) in fields">
<td>{{ i }}</td>
<td>
<a class="has-text-primary" @click="openField(v, i)">{{ v.name }}</a>
</td>
<td>{{ $stripHtml(v.label, 50) }}</td>
<td>
<a class="mr-4" @click="moveDown(v, i)">
<SvgIcon v-bind="{ name: 'down1.png', type: 'dark', size: 18 }"></SvgIcon>
</a>
<a class="mr-4" @click="moveUp(v, i)">
<SvgIcon v-bind="{ name: 'up.png', type: 'dark', size: 18 }"></SvgIcon>
</a>
<a @click="askConfirm(v, i)">
<SvgIcon v-bind="{ name: 'bin1.svg', type: 'dark', size: 18 }"></SvgIcon>
</a>
</td>
</tr>
</tbody>
</table>
<Modal @close="showmodal = undefined" @update="update" @confirm="remove" v-bind="showmodal" v-if="showmodal"></Modal>
</template>
<script setup>
import { useStore } from "@/stores/index";
const emit = defineEmits(["close"]);
const { $stripHtml, $clone, $arrayMove, $remove } = useNuxtApp();
const store = useStore();
var props = defineProps({
pagename: String,
});
var showmodal = ref();
var current;
var pagedata = store[props.pagename];
var fields = ref(pagedata.fields);
function openField(v, i) {
current = { v: v, i: i };
showmodal.value = {
component: "datatable/FieldAttribute",
title: `${v.name} / ${$stripHtml(v.label)}`,
width: "50%",
height: "400px",
vbind: { field: v },
};
}
function update(data) {
fields.value[current.i] = data;
let copy = $clone(pagedata);
copy.fields = fields.value;
copy.update = { fields: fields.value };
store.commit(props.pagename, copy);
showmodal.value = undefined;
emit("close");
}
function updateField() {
let copy = $clone(pagedata);
copy.fields = fields.value;
copy.update = { fields: fields.value };
store.commit(props.pagename, copy);
}
function moveDown(v, i) {
let idx = i === fields.value.length - 1 ? 0 : i + 1;
fields.value = $arrayMove(fields.value, i, idx);
updateField();
}
function moveUp(v, i) {
let idx = i === 0 ? fields.value.length - 1 : i - 1;
fields.value = $arrayMove(fields.value, i, idx);
updateField();
}
function askConfirm(v, i) {
current = { v: v, i: i };
showmodal.value = {
component: `dialog/Confirm`,
vbind: { content: "Bạn có muốn xóa cột này không?", duration: 10 },
title: "Xóa cột",
width: "500px",
height: "100px",
};
}
function remove() {
let arr = [current.v];
arr.map((v) => {
let idx = fields.value.findIndex((x) => x.name === v.name);
$remove(fields.value, idx);
});
updateField();
}
</script>

View File

@@ -0,0 +1,112 @@
<template>
<div class="field is-horizontal">
<div class="field-body">
<div class="field">
<label class="label fs-14"> Cỡ chữ của bảng <span class="has-text-danger"> * </span> </label>
<p class="control fs-14">
<input class="input is-small" type="text" :value="tablesetting.find(v=>v.code==='table-font-size').detail"
@change="changeSetting($event.target.value, 'table-font-size')">
</p>
</div>
<div class="field" >
<label class="label fs-14"> Cỡ chữ tiêu đề <span class="has-text-danger"> * </span> </label>
<p class="control fs-14">
<p class="control fs-14">
<input class="input is-small" type="text" :value="tablesetting.find(v=>v.code==='header-font-size').detail"
@change="changeSetting($event.target.value, 'header-font-size')">
</p>
</p>
</div>
<div class="field">
<label class="label fs-14"> Số dòng trên 1 trang <span class="has-text-danger"> * </span> </label>
<p class="control fs-14">
<input class="input is-small" type="text" :value="tablesetting.find(v=>v.code==='per-page').detail"
@change="changeSetting($event.target.value, 'per-page')">
</p>
</div>
</div>
</div>
<div class="field is-horizontal mt-5">
<div class="field-body">
<div class="field">
<label class="label fs-14"> Màu nền bảng <span class="has-text-danger"> * </span> </label>
<p class="control fs-14">
<input type="color" :value="tablesetting.find(v=>v.code==='table-background').detail"
@change="changeSetting($event.target.value, 'table-background')">
</p>
</div>
<div class="field">
<label class="label fs-14"> Màu chữ <span class="has-text-danger"> * </span> </label>
<p class="control fs-14">
<input type="color" :value="tablesetting.find(v=>v.code==='table-font-color').detail"
@change="changeSetting($event.target.value, 'table-font-color')">
</p>
</div>
</div>
</div>
<div class="field is-horizontal mt-5">
<div class="field-body">
<div class="field">
<label class="label fs-14"> Màu chữ tiêu đề <span class="has-text-danger"> * </span> </label>
<p class="control fs-14">
<input type="color" :value="tablesetting.find(v=>v.code==='header-font-color').detail"
@change="changeSetting($event.target.value, 'header-font-color')">
</p>
</div>
<div class="field">
<label class="label fs-14"> Màu nền tiêu đề <span class="has-text-danger"> * </span> </label>
<p class="control fs-14">
<input type="color" :value="tablesetting.find(v=>v.code==='header-background').detail"
@change="changeSetting($event.target.value, 'header-background')">
</p>
</div>
<div class="field">
<label class="label fs-14"> Màu chữ khi filter<span class="has-text-danger"> * </span> </label>
<p class="control fs-14">
<input type="color" :value="tablesetting.find(v=>v.code==='header-filter-color').detail"
@change="changeSetting($event.target.value, 'header-filter-color')">
</p>
</div>
</div>
</div>
<div class="field is-horizontal mt-5">
<div class="field-body">
<div class="field" >
<label class="label fs-14"> Đường viền <span class="has-text-danger"> * </span> </label>
<p class="control fs-14">
<input class="input is-small" type="text"
:value="tablesetting.find(v=>v.code==='td-border')? tablesetting.find(v=>v.code==='td-border').detail : undefined"
@change="changeSetting($event.target.value, 'td-border')">
</p>
</div>
</div>
</div>
</template>
<script setup>
import { useStore } from '@/stores/index'
const store = useStore()
var props = defineProps({
pagename: String
})
const { $copy, $clone, $empty } = useNuxtApp()
var pagedata = $clone(store[props.pagename])
var errors = []
var radioNote = 'no'
var tablesetting = pagedata.tablesetting
let found = tablesetting.find(v=>v.code==='note')
if(found? found.detail!=='@' : false) radioNote = 'yes'
function changeSetting(value, code) {
if(code==='note' && $empty(value)) return
let copy = $copy(tablesetting)
let found = copy.find(v=>v.code===code)
if(found) found.detail = value
else {
found = $copy(tablesetting.find(v=>v.code===code))
found.detail = value
copy.push(found)
}
tablesetting = copy
pagedata.tablesetting = tablesetting
store.commit(props.pagename, pagedata)
}
</script>

View File

@@ -0,0 +1,365 @@
<template>
<div class="pb-1" style="border-bottom: 2px solid #3c5b63" v-if="array || !enableTime">
<div class="columns mx-0 mb-0">
<div class="column is-8 px-0 pb-0" v-if="enableTime">
<div class="field is-grouped is-grouped-multiline mb-0">
<div class="control mb-0">
<Caption v-bind="{ title: lang === 'vi' ? 'Thời gian' : 'Time', type: 'has-text-warning' }" />
</div>
<div class="control mb-0" v-for="v in array" :key="v.code">
<span class="icon-text fsb-16 has-text-warning px-1" v-if="v.code === current">
<SvgIcon v-bind="{ name: 'tick.png', size: 20 }"></SvgIcon>
<span>{{ v.name }}</span>
</span>
<span class="icon-text has-text-grey hyperlink px-1 fsb-16" @click="changeOption(v)" v-else>{{
v.name
}}</span>
</div>
<span v-if="newDataAvailable" class="has-text-danger is-italic is-size-6 ml-2"> dữ liệu mới, vui lòng làm
mới.</span>
</div>
</div>
<div class="column is-4 px-0">
<div class="field is-grouped is-grouped-multiline mb-0">
<div class="control mb-0">
<Caption v-bind="{
type: 'has-text-warning',
title: lang === 'vi' ? `Tìm ${viewport === 1 ? '' : 'kiếm'}` : 'Search',
}" />
</div>
<div class="control mb-0">
<input class="input is-small" type="text" v-model="text"
:style="`${viewport === 1 ? 'width:150px;' : ''} border: 1px solid #BEBEBE;`" @keyup="startSearch"
id="input" :placeholder="lang === 'vi' ? 'Nhập từ khóa...' : 'Enter keyword...'" />
</div>
<div class="control mb-0">
<span class="tooltip" v-if="importdata && $getEditRights()">
<a class="mr-2" @click="openImport()">
<SvgIcon v-bind="{
name: 'upload.svg',
type: 'findata',
size: 22
}"></SvgIcon>
</a>
<span class="tooltiptext" style="min-width: max-content">
{{ lang === "vi" ? "Nhập dữ liệu" : "Import data" }}
</span>
</span>
<span class="tooltip" v-if="enableAdd && $getEditRights()">
<a class="mr-2" @click="$emit('add')">
<SvgIcon v-bind="{ name: 'add1.png', type: 'findata', size: 22 }"></SvgIcon>
</a>
<span class="tooltiptext" style="min-width: max-content">{{
lang === "vi" ? "Thêm mới" : "Add new"
}}</span>
</span>
<span class="tooltip">
<a class="mr-2" @click="$emit('excel')">
<SvgIcon v-bind="{ name: 'excel.png', type: 'findata', size: 22 }"></SvgIcon>
</a>
<span class="tooltiptext" style="min-width: max-content">{{
lang === "vi" ? "Xuất excel" : "Export excel"
}}</span>
</span>
<span class="tooltip">
<a @click="$emit('refresh-data')">
<SvgIcon v-bind="{ name: 'refresh.svg', type: 'findata', size: 22 }"></SvgIcon>
</a>
<span class="tooltiptext" style="min-width: max-content">{{
lang === "vi" ? "Làm mới" : "Refresh"
}}</span>
</span>
<a class="button is-primary is-loading is-small ml-3" v-if="loading"></a>
</div>
</div>
</div>
</div>
<Modal @close="showmodal = undefined" v-bind="showmodal" v-if="showmodal"></Modal>
</div>
</template>
<script>
import { useStore } from "@/stores/index"
export default {
setup() {
const store = useStore()
return { store }
},
props: ["pagename", "api", "timeopt", "filter", "realtime", "newDataAvailable", "params", "importdata"],
data() {
return {
options: [
{ code: 0, name: this.lang === "vi" ? "Hôm nay" : "Today" },
{ code: 1, name: "1D" },
{ code: 7, name: "7D" },
{ code: 30, name: "1M" },
{ code: 90, name: "3M" },
{ code: 180, name: "6M" },
{ code: 360, name: "1Y" },
{ code: 36000, name: this.lang === "vi" ? "Tất cả" : "All" },
],
showmodal: undefined,
current: 7,
search: undefined,
timer: undefined,
text: undefined,
status: [{ code: 100, name: "Chọn" }],
array: undefined,
enableAdd: true,
enableTime: true,
choices: [], // Sẽ được cập nhật động từ pagedata
viewport: this.store.viewport,
pagedata: undefined,
loading: false,
pollingInterval: null,
searchableFields: [] // Lưu thông tin các field có thể tìm kiếm
}
},
watch: {
pagename(newVal) {
this.updateSearchableFields()
}
},
async created() {
// Cập nhật searchable fields ngay từ đầu
this.updateSearchableFields()
if (this.viewport < 5) {
this.options = [
{ code: 0, name: "Hôm nay" },
{ code: 1, name: "1D" },
{ code: 7, name: "7D" },
{ code: 30, name: "1M" },
{ code: 90, name: "3M" },
{ code: 36000, name: "Tất cả" },
]
}
this.checkTimeopt()
if (!this.enableTime) return this.$emit("option")
let found = this.$findapi(this.api)
found.commit = undefined
let filter = this.$copy(this.filter)
if (filter) {
//dynamic parameter
for (const [key, value] of Object.entries(filter)) {
if (value.toString().indexOf("$") >= 0) {
filter[key] = this.store[value.replace("$", "")].id
}
}
}
if (found.params.filter) {
if (!filter) filter = {}
for (const [key, value] of Object.entries(found.params.filter)) {
filter[key] = value
}
}
this.options.map((v) => {
let f = filter ? this.$copy(filter) : {}
f["create_time__date__gte"] = this.$dayjs()
.subtract(v.code, "day")
.format("YYYY-MM-DD")
v.filter = f
})
this.$emit("option", this.$find(this.options, { code: this.current }))
let f = {}
this.options.map((v) => {
f[`${v.code}`] = {
type: "Count",
field: "create_time__date",
filter: v.filter
}
})
let params = { summary: "aggregate", distinct_values: f }
found.params = params
try {
let rs = await this.$getapi([found])
for (const [key, value] of Object.entries(rs[0].data.rows)) {
let found = this.$find(this.options, { code: Number(key) })
if (found) {
found.name = `${found.name} (${value})`
}
}
} catch (error) {
console.error("Error fetching data:", error)
}
this.array = this.$copy(this.options)
},
mounted() {
if (this.realtime) {
const interval = typeof this.realtime === "number" ? this.realtime * 1000 : 5000
this.pollingInterval = setInterval(this.refresh, interval)
}
},
beforeUnmount() {
if (this.pollingInterval) {
clearInterval(this.pollingInterval)
}
},
computed: {
lang: function () {
return this.store.lang
},
},
methods: {
// Lấy các field có thể tìm kiếm từ API params.values
updateSearchableFields() {
try {
// Lấy API config
const found = this.$findapi(this.api)
if (!found) {
console.warn('Không tìm thấy API config')
this.choices = []
this.searchableFields = []
return
}
// Ưu tiên lấy values từ props.params, nếu không có thì lấy từ API config
let valuesString = ''
if (this.params && this.params.values) {
// Lấy từ props.params (ưu tiên cao nhất)
valuesString = this.params.values
} else if (found.params && found.params.values) {
// Lấy từ API config mặc định
valuesString = found.params.values
} else {
console.warn('Không tìm thấy API values trong props hoặc config')
this.choices = []
this.searchableFields = []
return
}
// Parse values string từ API
let fieldNames = valuesString.split(',').map(v => v.trim())
// Lấy pagedata để lấy label
this.pagedata = this.store[this.pagename]
// Lấy tất cả các field để search (không lọc format)
const searchable = fieldNames.filter(fieldName => {
// Loại bỏ các field kỹ thuật
if (fieldName === 'id' ||
fieldName === 'create_time' ||
fieldName === 'update_time' ||
fieldName === 'created_at' ||
fieldName === 'updated_at') {
return false
}
return true
})
// Lấy tên và label các field
this.choices = searchable
this.searchableFields = searchable.map(fieldName => {
// Lấy field base name (trước dấu __)
const baseFieldName = fieldName.split('__')[0]
const fieldInfo = this.pagedata && this.pagedata.fields
? this.pagedata.fields.find(f => f.name === baseFieldName)
: null
return {
name: fieldName,
label: fieldInfo ? fieldInfo.label : fieldName
}
})
} catch (error) {
console.error('Error updating searchable fields:', error)
this.choices = []
this.searchableFields = []
}
},
refresh() {
let found = this.$find(this.options, { code: this.current })
this.changeOption(found)
},
changeOption(v) {
this.current = v.code
if (this.search) {
this.text = undefined
this.search = undefined
}
this.$emit("option", this.$find(this.array, { code: this.current }))
},
doSearch() {
// Cập nhật choices trước khi search
this.updateSearchableFields()
this.pagedata = this.store[this.pagename]
if (!this.pagedata || !this.pagedata.fields) {
console.warn('Không có pagedata hoặc fields')
return
}
let fields = this.pagedata.fields.filter(
(v) => this.choices.findIndex((x) => x === v.name) >= 0
)
if (fields.length === 0) {
console.warn('Không tìm thấy field để tìm kiếm')
return
}
let f = {}
fields.map((v) => {
f[`${v.name}__icontains`] = this.search
})
this.$emit("option", { filter_or: f })
},
openImport() {
if (!this.importdata) return
// Emit event lên parent (DataView)
this.$emit('import', this.importdata)
},
startSearch(val) {
this.search = this.$empty(val.target.value)
? ""
: val.target.value.trim()
if (this.timer) clearTimeout(this.timer)
this.timer = setTimeout(() => this.doSearch(), 300)
},
checkTimeopt() {
if (this.timeopt > 0) {
let obj = this.$find(this.options, { code: this.$formatNumber(this.timeopt) })
if (obj) this.current = obj.code
}
if (this.timeopt ? this.$empty(this.timeopt.disable) : true) return
if (this.timeopt.disable.indexOf("add") >= 0) this.enableAdd = false
if (this.timeopt.disable.indexOf("time") >= 0) this.enableTime = false
if (this.timeopt.time) {
let obj = this.$find(this.options, { code: this.$formatNumber(this.timeopt.time) })
if (obj) this.current = obj.code
}
},
},
}
</script>

View File

@@ -0,0 +1,10 @@
<template>
<span :style="{ color: color }">{{ text }}</span>
</template>
<script setup>
const props = defineProps({
text: { type: String, required: true },
color: { type: String, default: "#000" }
})
</script>

View File

@@ -0,0 +1,10 @@
<template>
<span :style="color? `color:${color}` : ''">{{ $dayjs(date).format('DD/MM/YYYY') }}</span>
</template>
<script setup>
const { $dayjs } = useNuxtApp()
const props = defineProps({
date: String,
color: String
})
</script>

View File

@@ -0,0 +1,10 @@
<template>
<span :style="color? `color:${color}` : ''">{{ value === 0 || value === null ? '-' : $numtoString(value) }}</span>
</template>
<script setup>
const { $numtoString } = useNuxtApp()
const props = defineProps({
value: Number,
color: String
})
</script>

View File

@@ -0,0 +1,10 @@
<template>
<span :style="color? `color:${color}` : ''">{{ $numtoString(value) }}</span>
</template>
<script setup>
const { $numtoString } = useNuxtApp()
const props = defineProps({
value: Number,
color: String
})
</script>

View File

@@ -0,0 +1,130 @@
<template>
<div class="control has-icons-left" :id="docid">
<div :class="`dropdown ${pos || ''} ${focused ? 'is-active' : ''}`" style="width: 100%">
<div class="dropdown-trigger" style="width: 100%;">
<input :disabled="disabled" :class="`input ${error? 'is-danger' : ''} ${disabled? 'has-text-dark' : ''}`" type="text" placeholder="DD/MM/YYYY"
maxlength="10" @focus="setFocus" @blur="lostFocus" @keyup.enter="pressEnter" @keyup="checkDate" v-model="show" />
</div>
<div class="dropdown-menu" role="menu" @click="doClick()">
<div class="dropdown-content">
<PickDay v-bind="{ date, maxdate }" @date="selectDate"></PickDay>
</div>
</div>
</div>
<span class="icon is-left">
<SvgIcon v-bind="{name: 'calendar.svg', type: 'gray', size: 21}"></SvgIcon>
</span>
</div>
</template>
<script>
export default {
props: ['record', 'attr', 'position', 'mindate', 'maxdate', 'disabled'],
data() {
return {
date: undefined,
show: undefined,
error: false,
focused: false,
docid: this.$id(),
count1: 0,
count2: 0,
pos: undefined
}
},
created() {
this.getPos()
if(this.record) {
this.date = this.record[this.attr]? this.$copy(this.record[this.attr]) : undefined
if(this.date) this.show = this.$dayjs(this.date).format('DD/MM/YYYY')
}
},
watch: {
record: function(newVal) {
if(this.record) {
this.date = this.record[this.attr]? this.$copy(this.record[this.attr]) : undefined
if(this.date) this.show = this.$dayjs(this.date).format('DD/MM/YYYY')
}
}
},
methods: {
pressEnter() {
this.checkDate()
if(!this.error) this.focused = false
},
setFocus() {
this.focused = true
this.count1 = 0
this.count2 = 0
},
lostFocus() {
let self = this
setTimeout(()=>{
if(self.focused && self.count1===0) self.focused = false
}, 200)
},
processEvent(event) {
var doc = document.getElementById(this.docid)
if(!doc) return
this.count2 += 1
var isClickInside = false
isClickInside = doc.contains(event.target);
if(!isClickInside && this.focused) {
if(this.count2-1!==this.count1) {
this.focused = false
this.count1 = 0
this.count2 = 0
}
}
},
doClick() {
this.count1 += 1
},
selectDate(v) {
this.date = v
this.show = this.$dayjs(v).format('DD/MM/YYYY')
this.$emit('date', this.date)
if(this.focused) this.focused = false
this.count1 = 0
this.count2 = 0
},
getDate(value) {
let v = value.replace(/\D/g,'').slice(0, 10);
if (v.length >= 5) {
return `${v.slice(0,2)}/${v.slice(2,4)}/${v.slice(4)}`;
}
else if (v.length >= 3) {
return `${v.slice(0,2)}/${v.slice(2)}`;
}
return v
},
checkDate() {
if(!this.focused) this.setFocus()
this.error = false
this.date = undefined
if(this.$empty(this.show)) return this.$emit('date', null)
this.show = this.getDate(this.show)
let val = `${this.show.substring(6,10)}-${this.show.substring(3,5)}-${this.show.substring(0,2)}`
if(this.$dayjs(val, "YYYY-MM-DD", true).isValid()) {
this.date = val
this.$emit('date', this.date)
} else this.error = true
},
getPos() {
switch(this.position) {
case 'is-top-left':
this.pos = 'is-up is-left'
break;
case 'is-top-right':
this.pos = 'is-up is-right'
break;
case 'is-bottom-left':
this.pos = 'is-left'
break;
case 'is-bottom-right':
this.pos = 'is-right'
break;
}
}
}
}
</script>

View File

@@ -0,0 +1,185 @@
<template>
<div>
<div class="field is-grouped mb-4 border-bottom" v-if="1<0">
<div class="control pl-2" v-if="mode!=='simple'">
<a class="mr-1" @click="previousYear()">
<SvgIcon v-bind="{name: 'doubleleft.svg', type: 'gray', size: 18}"></SvgIcon>
</a>
<a @click="previousMonth()" v-if="type==='days'">
<SvgIcon v-bind="{name: 'left1.svg', type: 'gray', size: 18}"></SvgIcon>
</a>
</div>
<div class="control is-expanded has-text-centered">
<span class="fsb-16 hyperlink mr-3" @click="type='months'" v-if="type==='days'">{{`Tháng ${month}`}}</span>
<span class="fsb-16 hyperlink" @click="type='years'">{{ caption || year }}</span>
</div>
<div class="control pr-2" v-if="mode!=='simple'">
<a class="mr-1" @click="nextMonth()" v-if="type==='days'">
<SvgIcon v-bind="{name: 'right.svg', type: 'gray', size: 18}"></SvgIcon>
</a>
<a @click="nextYear()">
<SvgIcon v-bind="{name: 'doubleright.svg', type: 'gray', size: 18}"></SvgIcon>
</a>
</div>
</div>
<div v-if="type==='days'">
<div :class="`columns mx-0 ${i===weeks.length-1? 'mb-1' : ''}`" v-for="(v,i) in weeks" :key="i">
<div class="column px-3 py-1 has-text-centered" v-for="(m,h) in v.dates" :key="h" style="min-height: 100px;"
:style="`border-right: 1px solid #DCDCDC; border-bottom: 1px solid #DCDCDC; ${(viewport>1 && h===0 || viewport===1)? 'border-left: 1px solid #DCDCDC;' : ''}
${i===0? 'border-top: 1px solid #DCDCDC;' : ''}`">
<p class="mb-1" v-if="i===0"><b>{{ $find(dateOfWeek, {id: h}).text}}</b></p>
<span class="has-background-primary has-text-white px-1 py-1" v-if="m.date===today">{{ m.dayPrint }}</span>
<span v-else>{{m.dayPrint}}</span>
<div class="has-text-left fs-15 mt-1" v-if="m.event && m.currentMonth===m.mothCondition">
<p :class="`pt-1 ${j===m.event.length-1? '' : 'border-bottom'}`" v-for="(h,j) in m.event">
<SvgIcon v-bind="{name: h.icon, type: h.color, size: 16}"></SvgIcon>
<a class="ml-3" @click="openEvent(h)">{{ h.text }}</a>
</p>
</div>
</div>
</div>
</div>
<Modal @close="showmodal=undefined" v-bind="showmodal" v-if="showmodal"></Modal>
</div>
</template>
<script>
export default {
props: ['date', 'events', 'mode', 'vyear', 'vmonth'],
data() {
return {
dates: [],
dateOfWeek: [{id: 0, text: "CN"}, {id: 1, text: "T2"}, {id: 2, text: "T3"}, {id: 3, text: "T4"},
{id: 4, text: "T5",}, {id: 5, text: "T6"}, {id: 6, text: "T7"}],
weeks: [],
today: this.$dayjs().format('YYYY/MM/DD'),
year: undefined,
month: undefined,
type: 'days',
caption: undefined,
action: undefined,
curdate: undefined,
showmodal: undefined,
viewport: 5
}
},
created() {
this.showDate()
},
watch: {
date: function(newVal) {
if(newVal) this.showDate()
},
vmonth: function(newVal) {
this.showDate()
},
events: function(newVal) {
this.showDate()
}
},
methods: {
async openEvent(event) {
let row = await this.$getdata('sale', {id: event.sale}, undefined, true)
this.showmodal = {title: 'Bán hàng', height: '500px', width: '90%', component: 'sale/Sale', vbind: {row: row, highlight: event.id}}
},
compiledComponent(value) {
return {
template: `${value}`
}
},
showDate() {
this.curdate = this.date? this.date.replaceAll('-', '/') : undefined
this.year = this.$dayjs(this.curdate || this.today).year()
this.month = this.$dayjs(this.curdate || this.today).month() + 1
if(this.vyear) this.year = this.$copy(this.vyear)
if(this.vmonth) this.month = this.$copy(this.vmonth)
this.getDates()
},
chooseToday() {
this.$emit('date', this.today.replaceAll('/', '-'))
this.year = this.$dayjs(this.today).year()
this.month = this.$dayjs(this.today).month() + 1
this.getDates()
},
changeCaption(v) {
this.caption = v
},
selectMonth(v) {
this.month = v
this.getDates()
this.type = 'days'
},
selectYear(v) {
this.year = v
this.getDates()
this.type = 'days'
},
getDates() {
this.caption = undefined
this.dates = this.allDaysInMonth(this.year, this.month)
this.dates.map(v=>{
let event = this.events? this.$filter(this.events, {isodate: v.date}) : undefined
if(event.length>0) v.event = event
})
this.weeks = this.$unique(this.dates, ['week']).map(v=>{return {week: v.week}})
this.weeks.map(v=>{
v.dates = this.dates.filter(x=>x.week===v.week)
})
},
nextMonth() {
let month = this.month + 1
if(month>12) {
month = 1
this.year += 1
}
this.month = month
this.getDates()
},
previousMonth() {
let month = this.month - 1
if(month===0) {
month = 12
this.year -= 1
}
this.month = month
this.getDates()
},
nextYear() {
if(this.type==='years') return this.action = {name: 'next', id: this.$id()}
this.year += 1
this.getDates()
},
previousYear() {
if(this.type==='years') return this.action = {name: 'previous', id: this.$id()}
this.year -= 1
this.getDates()
},
choose(m) {
this.$emit('date', m.date.replaceAll('/', '-'))
},
createDate(v, x, y) {
return v + '/' + (x<10? '0' + x.toString() : x.toString()) + '/' + (y<10? '0' + y.toString() : y.toString())
},
allDaysInMonth(year, month) {
let days = Array.from({length: this.$dayjs(this.createDate(year, month, 1)).daysInMonth()}, (_, i) => i + 1)
let arr = []
days.map(v=>{
for (let i = 0; i < 7; i++) {
let thedate = this.$dayjs(this.createDate(year, month, v)).weekday(i)
let date = this.$dayjs(new Date(thedate.$d)).format("YYYY/MM/DD")
let dayPrint = this.$dayjs(new Date(thedate.$d)).format("DD")
let mothCondition = this.$dayjs(date).month() +1
let currentMonth = month
let found = arr.find(x=>x.date===date)
if(!found) {
let dayOfWeek = this.$dayjs(date).day()
let week = this.$dayjs(date).week()
let ele = {date: date, week: week, day: v, dayOfWeek: dayOfWeek, dayPrint: dayPrint, mothCondition: mothCondition, currentMonth: currentMonth}
arr.push(ele)
}
}
})
return arr
}
}
}
</script>

View File

@@ -0,0 +1,19 @@
<template>
<div>
<div class="columns is-multiline mx-0">
<div class="column is-4" v-for="v in months">
<EventSummary v-bind="{events: events, mode: 'simple', vyear: v.year, vmonth: v.month}"></EventSummary>
</div>
</div>
</div>
</template>
<script>
import EventSummary from '@/components/datepicker/EventSummary'
export default {
components: {
EventSummary
//EventSummary: () => import('@/components/datepicker/EventSummary')
},
props: ['events', 'months']
}
</script>

View File

@@ -0,0 +1,52 @@
<template>
<div class="columns mx-0">
<div class="column is-narrow" v-if="1<0">
<EventSummary></EventSummary>
</div>
<div class="column">
<div class="mb-4">
<span class="fsb-17 mr-4">{{ `T${vmonth}/${vyear}` }}</span>
<a class="mr-2" @click="previous()"><SvgIcon v-bind="{name: 'left1.svg', type: 'dark', size: 18}"></SvgIcon></a>
<a class="mr-3" @click="next()"><SvgIcon v-bind="{name: 'right.svg', type: 'dark', size: 18}"></SvgIcon></a>
<a @click="refresh()"><SvgIcon v-bind="{name: 'refresh.svg', type: 'dark', size: 18}"></SvgIcon></a>
</div>
<EventDetail v-bind="{events: events, vyear: vyear, vmonth: vmonth}"></EventDetail>
</div>
</div>
</template>
<script setup>
import EventSummary from '@/components/datepicker/EventSummary'
import EventDetail from '@/components/datepicker/EventDetail'
</script>
<script>
export default {
props: ['events', 'year', 'month'],
data() {
return {
vyear: this.year? this.$copy(this.year) : undefined,
vmonth: this.month? this.$copy(this.month) : undefined
}
},
methods: {
next() {
let month = this.vmonth + 1
if(month>12) {
month = 1
this.vyear += 1
}
this.vmonth = month
},
previous() {
let month = this.vmonth - 1
if(month===0) {
month = 12
this.vyear -= 1
}
this.vmonth = month
},
refresh() {
this.$emit('refresh')
}
}
}
</script>

View File

@@ -0,0 +1,189 @@
<template>
<div>
<div class="field is-grouped mb-4 border-bottom">
<div class="control pl-2" v-if="mode!=='simple'">
<a class="mr-1" @click="previousYear()">
<SvgIcon v-bind="{name: 'doubleleft.svg', type: 'gray', size: 18}"></SvgIcon>
</a>
<a @click="previousMonth()" v-if="type==='days'">
<SvgIcon v-bind="{name: 'left1.svg', type: 'gray', size: 18}"></SvgIcon>
</a>
</div>
<div class="control is-expanded has-text-centered">
<span class="fsb-16 hyperlink mr-3" @click="mode==='simple'? false : type='PickMonth'" v-if="type==='days'">{{`Tháng ${month}`}}</span>
<span class="fsb-16 hyperlink" @click="mode==='simple'? false : type='PickYear'">{{ caption || year }}</span>
</div>
<div class="control pr-2" v-if="mode!=='simple'">
<a class="mr-1" @click="nextMonth()" v-if="type==='days'">
<SvgIcon v-bind="{name: 'right.svg', type: 'gray', size: 18}"></SvgIcon>
</a>
<a @click="nextYear()">
<SvgIcon v-bind="{name: 'doubleright.svg', type: 'gray', size: 18}"></SvgIcon>
</a>
</div>
</div>
<div v-if="type==='days'">
<div class="columns is-mobile mx-0">
<div class="column px-2 py-1 has-text-grey-dark" v-for="(m,h) in dateOfWeek" :key="h">
{{ m.text }}
</div>
</div>
<div :class="`columns is-mobile mx-0 ${i===weeks.length-1? 'mb-1' : ''}`" v-for="(v,i) in weeks" :key="i">
<div class="column px-3 fs-14" v-for="(m,h) in v.dates" :key="h" style="padding-top: 1px; padding-bottom: 1px;">
<a v-if="m.event && m.currentMonth===m.mothCondition">
<Tooltip v-bind="{html: m.event.html, tooltip: m.event.tooltip}"></Tooltip>
</a>
<span class="has-background-findata has-text-white px-1 py-1" v-else-if="m.date===today">{{ m.dayPrint }}</span>
<span v-else>{{m.dayPrint}}</span>
</div>
</div>
<template v-if="mode!=='simple'">
<div class="border-bottom"></div>
<div class="mt-2">
<span class="ml-2 mr-2">Hôm nay: </span>
<span class="has-text-primary hyperlink" @click="chooseToday()">{{ $dayjs(today).format('DD/MM/YYYY') }}</span>
</div>
</template>
</div>
<div v-else-if="type==='PickMonth'">
<PickMonth @month="selectMonth"></PickMonth>
</div>
<div v-else-if="type==='PickYear'">
<PickYear v-bind="{year: year, month: month, action: action}" @year="selectYear" @caption="changeCaption"></PickYear>
</div>
<Modal @close="showmodal=undefined" v-bind="showmodal" v-if="showmodal"></Modal>
</div>
</template>
<script>
export default {
components: {
PickMonth: () => import('@/components/datepicker/PickMonth'),
PickYear: () => import('@/components/datepicker/PickYear')
},
props: ['date', 'events', 'mode', 'vyear', 'vmonth'],
data() {
return {
dates: [],
dateOfWeek: [{id: 0, text: "CN"}, {id: 1, text: "T2"}, {id: 2, text: "T3"}, {id: 3, text: "T4"},
{id: 4, text: "T5",}, {id: 5, text: "T6"}, {id: 6, text: "T7"}],
weeks: [],
today: this.$dayjs().format('YYYY/MM/DD'),
year: undefined,
month: undefined,
type: 'days',
caption: undefined,
action: undefined,
curdate: undefined,
showmodal: undefined
}
},
created() {
this.showDate()
},
watch: {
date: function(newVal) {
if(newVal) this.showDate()
},
events: function(newVal) {
this.showDate()
}
},
methods: {
showDate() {
this.curdate = this.date? this.date.replaceAll('-', '/') : undefined
this.year = this.$dayjs(this.curdate || this.today).year()
this.month = this.$dayjs(this.curdate || this.today).month() + 1
if(this.vyear) this.year = this.$copy(this.vyear)
if(this.vmonth) this.month = this.$copy(this.vmonth)
this.getDates()
},
chooseToday() {
this.$emit('date', this.today.replaceAll('/', '-'))
this.year = this.$dayjs(this.today).year()
this.month = this.$dayjs(this.today).month() + 1
this.getDates()
},
changeCaption(v) {
this.caption = v
},
selectMonth(v) {
this.month = v
this.getDates()
this.type = 'days'
},
selectYear(v) {
this.year = v
this.getDates()
this.type = 'days'
},
getDates() {
this.caption = undefined
this.dates = this.allDaysInMonth(this.year, this.month)
this.dates.map(v=>{
let event = this.events? this.$find(this.events, {isodate: v.date}) : undefined
if(event) v.event = event
})
this.weeks = this.$unique(this.dates, ['week']).map(v=>{return {week: v.week}})
this.weeks.map(v=>{
v.dates = this.dates.filter(x=>x.week===v.week)
})
},
nextMonth() {
let month = this.month + 1
if(month>12) {
month = 1
this.year += 1
}
this.month = month
this.getDates()
},
previousMonth() {
let month = this.month - 1
if(month===0) {
month = 12
this.year -= 1
}
this.month = month
this.getDates()
},
nextYear() {
if(this.type==='PickYear') return this.action = {name: 'next', id: this.$id()}
this.year += 1
this.getDates()
},
previousYear() {
if(this.type==='PickYear') return this.action = {name: 'previous', id: this.$id()}
this.year -= 1
this.getDates()
},
choose(m) {
this.$emit('date', m.date.replaceAll('/', '-'))
},
createDate(v, x, y) {
return v + '/' + (x<10? '0' + x.toString() : x.toString()) + '/' + (y<10? '0' + y.toString() : y.toString())
},
allDaysInMonth(year, month) {
let days = Array.from({length: this.$dayjs(this.createDate(year, month, 1)).daysInMonth()}, (_, i) => i + 1)
let arr = []
days.map(v=>{
for (let i = 0; i < 7; i++) {
let thedate = this.$dayjs(this.createDate(year, month, v)).weekday(i)
let date = this.$dayjs(new Date(thedate.$d)).format("YYYY/MM/DD")
let dayPrint = this.$dayjs(new Date(thedate.$d)).format("DD")
let mothCondition = this.$dayjs(date).month() +1
let currentMonth = month
let found = arr.find(x=>x.date===date)
if(!found) {
let dayOfWeek = this.$dayjs(date).day()
let week = this.$dayjs(date).week()
let ele = {date: date, week: week, day: v, dayOfWeek: dayOfWeek, dayPrint: dayPrint,
mothCondition: mothCondition, currentMonth: currentMonth}
arr.push(ele)
}
}
})
return arr
}
}
}
</script>

View File

@@ -0,0 +1,181 @@
<template>
<div class="p-2" style="width: 300px">
<div class="field is-grouped mb-4 border-bottom">
<div class="control pl-2">
<a class="mr-1" @click="previousYear()">
<SvgIcon v-bind="{name: 'doubleleft.svg', type: 'gray', size: 18}"></SvgIcon>
</a>
<a @click="previousMonth()" v-if="type==='days'">
<SvgIcon v-bind="{name: 'left1.svg', type: 'gray', size: 18}"></SvgIcon>
</a>
</div>
<div class="control is-expanded has-text-centered">
<a class="fsb-16 mr-2" @click="type='months'" v-if="type==='days'">{{`T${month}`}}</a>
<a class="fsb-16" @click="type='years'">{{ caption || year }}</a>
</div>
<div class="control pr-2">
<a class="mr-1" @click="nextMonth()" v-if="type==='days'">
<SvgIcon v-bind="{name: 'right.svg', type: 'gray', size: 18}"></SvgIcon>
</a>
<a @click="nextYear()">
<SvgIcon v-bind="{name: 'doubleright.svg', type: 'gray', size: 18}"></SvgIcon>
</a>
</div>
</div>
<div v-if="type==='days'">
<div class="columns is-mobile mx-0 mb-3">
<div class="column p-0 has-text-grey-light-dark is-flex is-justify-content-center is-align-items-center" style="height: 32px;" v-for="(m,h) in dateOfWeek" :key="h">
{{ m.text }}
</div>
</div>
<div :class="`columns is-mobile mx-0 mb-1 ${i===weeks.length-1? 'mb-1' : ''}`" v-for="(v,i) in weeks" :key="i">
<div class="column p-0 is-flex is-justify-content-center is-align-items-center" style="width: 40px; height: 32px" v-for="(m,h) in v.dates" :key="h">
<span class="fs-14 has-text-grey-light" v-if="m.disable">
{{m.dayPrint}}
</span>
<a class="fs-14" @click="choose(m)" v-else>
<span
style="width: 25px; height: 25px; border-radius: 4px"
:class="[
'p-1 is-flex is-justify-content-center is-align-items-center',
{
'has-background-primary has-text-white': m.date === curdate,
'has-background-light has-text-white': m.date === today,
'has-text-grey-light': m.currentMonth !== m.monthCondition
}
]"
>
{{ m.dayPrint }}
</span>
</a>
</div>
</div>
<div class="border-bottom"></div>
<div class="mt-2">
<span class="ml-2">Hôm nay: </span>
<a class="has-text-primary" @click="chooseToday()">{{ $dayjs(today).format('DD/MM/YYYY') }}</a>
</div>
</div>
<PickMonth v-else-if="type==='months'" @month="selectMonth"></PickMonth>
<PickYear v-else-if="type==='years'" v-bind="{ year, month, action }" @year="selectYear" @caption="changeCaption"></PickYear>
</div>
</template>
<script setup>
import PickMonth from '@/components/datepicker/PickMonth'
import PickYear from '@/components/datepicker/PickYear'
const { $id, $dayjs, $unique} = useNuxtApp()
const emit = defineEmits(['date'])
var props = defineProps({
date: String,
maxdate: String
})
var dates = []
var dateOfWeek = [{id: 0, text: "CN"}, {id: 1, text: "T2"}, {id: 2, text: "T3"}, {id: 3, text: "T4"},
{id: 4, text: "T5",}, {id: 5, text: "T6"}, {id: 6, text: "T7"}]
var weeks = ref([])
var year= ref(undefined)
var month = undefined
var type = ref('days')
var caption = ref(undefined)
var action = ref(undefined)
var curdate = undefined
var today = new Date()
function showDate() {
curdate = props.date? props.date.replaceAll('-', '/') : undefined
year.value = $dayjs(curdate || today).year()
month = $dayjs(curdate || today).month() + 1
getDates()
}
function chooseToday() {
emit('date', $dayjs(today).format('YYYY-MM-DD') )//today.replaceAll('/', '-'))
year.value = $dayjs(today).year()
month = $dayjs(today).month() + 1
getDates()
}
function changeCaption(v) {
caption.value = v
}
function selectMonth(v) {
month = v
getDates()
type.value = 'days'
}
function selectYear(v) {
year.value = v
getDates()
type.value = 'days'
}
function getDates() {
caption.value = undefined
dates = allDaysInMonth(year.value, month)
weeks.value = $unique(dates, ['week']).map(v=>{return {week: v.week}})
weeks.value.map(v=>{
v.dates = dates.filter(x=>x.week===v.week)
})
}
function nextMonth() {
month = month + 1
if(month>12) {
month = 1
year.value += 1
}
getDates()
}
function previousMonth() {
month = month - 1
if(month===0) {
month = 12
year.value -= 1
}
getDates()
}
function nextYear() {
if(type.value==='years') return action.value = {name: 'next', id: $id()}
year.value += 1
getDates()
}
function previousYear() {
if(type.value==='years') return action.value = {name: 'previous', id: $id()}
year.value -= 1
getDates()
}
function choose(m) {
emit('date', m.date.replaceAll('/', '-'))
}
function createDate(v, x, y) {
return v + '/' + (x<10? '0' + x.toString() : x.toString()) + '/' + (y<10? '0' + y.toString() : y.toString())
}
function allDaysInMonth(year, month) {
let days = Array.from({length: $dayjs(createDate(year, month, 1)).daysInMonth()}, (_, i) => i + 1)
let arr = []
days.map(v=>{
for (let i = 0; i < 7; i++) {
let thedate = $dayjs(createDate(year, month, v)).weekday(i)
let date = $dayjs(new Date(thedate.$d)).format("YYYY/MM/DD")
let dayPrint = $dayjs(new Date(thedate.$d)).format("DD")
let monthCondition = $dayjs(date).month() +1
let currentMonth = month
let found = arr.find(x=>x.date===date)
if(!found) {
let dayOfWeek = $dayjs(date).day()
let week = $dayjs(date).week()
let disable = false
if(props.maxdate? $dayjs(props.maxdate).diff(props.date, 'day')>=0 : false) {
if($dayjs(props.maxdate).startOf('day').diff(date, 'day')<0) disable = true
}
let ele = {date: date, week: week, day: v, dayOfWeek: dayOfWeek, dayPrint: dayPrint, monthCondition: monthCondition,
currentMonth: currentMonth, disable: disable}
arr.push(ele)
}
}
})
return arr
}
// display
showDate()
// change date
watch(() => props.date, (newVal, oldVal) => {
showDate()
})
</script>

View File

@@ -0,0 +1,14 @@
<template>
<div class="columns is-mobile is-multiline mx-0">
<span class="column has-text-centered is-4 hyperlink fs-14" v-for="v in months" @click="$emit('month', v)">Tháng {{ v }}</span>
</div>
</template>
<script>
export default {
data() {
return {
months: [1,2,3,4,5,6,7,8,9,10,11,12]
}
}
}
</script>

View File

@@ -0,0 +1,57 @@
<template>
<div class="columns is-mobile is-multiline mx-0">
<span
v-for="(v,i) in years"
class="column is-4 has-text-centered hyperlink fs-14"
:class="i===0 || i===11 ? 'has-text-grey-light' : ''"
@click="$emit('year', v)">{{ v }}</span>
</div>
</template>
<script>
export default {
props: ['year', 'month', 'action'],
data() {
return {
years: []
}
},
created() {
this.years = [this.year]
for(let i = 1; i < 7; i++) {
this.years.push(this.year+i)
this.years.push(this.year-i)
}
this.years.sort(function(a, b) {return a - b;})
this.years = this.years.slice(0,12)
this.$emit('caption', `${this.years[1]}-${this.years[10]}`)
},
watch: {
action: function(newVal) {
if(newVal.name==='next') this.next()
if(newVal.name==='previous') this.previous()
}
},
methods: {
next() {
let year = this.years[this.years.length-1]
this.years = []
for(let i = 0; i < 12; i++) {
this.years.push(year+i)
}
this.years.sort(function(a, b) {return a - b;})
this.years = this.years.slice(0,12)
this.$emit('caption', `${this.years[1]}-${this.years[10]}`)
},
previous() {
let year = this.years[0]
this.years = []
for(let i = 0; i < 12; i++) {
this.years.push(year-i)
}
this.years.sort(function(a, b) {return a - b;})
this.years = this.years.slice(0,12)
this.$emit('caption', `${this.years[1]}-${this.years[10]}`)
}
}
}
</script>

View File

@@ -0,0 +1,16 @@
<template>
<div>
<span v-html="row.event.name"></span>
<!--<p v-for="v in data">{{ v.text }}</p>-->
</div>
</template>
<script>
export default {
props: ["row"],
data() {
return {
data: this.row.event.arr,
};
},
};
</script>

363
app/components/debt/Due.vue Normal file
View File

@@ -0,0 +1,363 @@
<script setup>
import Template1 from '@/lib/email/templates/Template1.vue';
import { render } from '@vue-email/render';
import { forEachAsync, isEqual } from 'es-toolkit';
const {
$dayjs,
$getdata,
$insertapi,
$numtoString,
$numberToVietnamese,
$numberToVietnameseCurrency,
$formatDateVN,
$getFirstAndLastName,
$snackbar,
$store,
} = useNuxtApp();
const payables = ref(null);
const defaultFilter = {
status: 1,
to_date__gte: $dayjs().format('YYYY-MM-DD'),
to_date__lte: undefined,
}
const filter = ref(defaultFilter);
const activeDateFilter = ref(null);
const key = ref(0);
function setDateFilter(detail) {
activeDateFilter.value = isEqual(activeDateFilter.value, detail) ? null : detail;
}
function resetDateFilter() {
activeDateFilter.value = null;
}
onMounted(async () => {
const payablesData = await $getdata('bizsetting', undefined, { filter: { classify: 'duepayables' }, sort: 'index' });
payables.value = payablesData;
});
watch(activeDateFilter, (val) => {
if (!val) {
filter.value = defaultFilter;
contents.value = null;
} else {
const cutoffDate = $dayjs().add(val.time, 'day').format('YYYY-MM-DD');
const filterField = `to_date__${val.lookup}`;
filter.value = {
...defaultFilter,
[filterField]: cutoffDate,
}
}
}, { deep: true })
const contents = ref(null);
const isSending = ref(false);
function sanitizeContentPayment(text, maxLength = 80) {
if (!text) return '';
return text
.normalize('NFD') // bỏ dấu tiếng Việt
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-zA-Z0-9 _-]/g, '') // bỏ ký tự lạ
.replace(/\s+/g, ' ')
.trim()
.slice(0, maxLength);
}
const buildQrHtml = (url) => `
<div style="text-align: center; margin-top: 16px">
<img
src="${url}"
alt="VietQR"
width="500"
style="display: inline-block"
/>
</div>
`;
const buildContentPayment = (data) => {
const {
txn_detail__transaction__customer__type__code: customerType,
txn_detail__transaction__customer__code: customerCode,
txn_detail__transaction__customer__fullname: customerName,
txn_detail__transaction__product__trade_code: productCode,
cycle,
} = data;
if (customerType.toLowerCase() === 'cn') {
if (customerName.length < 14) {
return `${productCode} ${customerCode} ${customerName} TT ${cycle == 0 ? 'Dat Coc' : `Dot ${cycle}`}`;
} else {
return `${productCode} ${customerCode} ${$getFirstAndLastName(customerName)} TT ${cycle == 0 ? 'Dat Coc' : `Dot ${cycle}`}`;
}
} else {
return `${productCode} ${customerCode} TT ${cycle == 0 ? 'Dat Coc' : `Dot ${cycle}`}`;
}
};
function replaceTemplateVars(html, paymentScheduleItem) {
const {
txn_detail__transaction__product__trade_code,
txn_detail__transaction__customer__code,
txn_detail__transaction__customer__fullname,
txn_detail__transaction__customer__legal_code,
txn_detail__transaction__customer__type__code,
txn_detail__transaction__customer__contact_address,
txn_detail__transaction__customer__address,
txn_detail__transaction__customer__phone,
from_date,
to_date,
remain_amount,
cycle
} = paymentScheduleItem;
return html
.replace(/\[day]/g, String(new Date().getDate()).padStart(2, '0') || '')
.replace(/\[month]/g, String(new Date().getMonth() + 1).padStart(2, '0') || '')
.replace(/\[year]/g, new Date().getFullYear() || '')
.replace(/\[product\.trade_code\]/g, txn_detail__transaction__product__trade_code)
.replace(/\[product\.trade_code_payment\]/g, sanitizeContentPayment(txn_detail__transaction__product__trade_code))
.replace(/\[customer\.fullname\]/g, txn_detail__transaction__customer__fullname)
.replace(
/\[customer\.name\]/g,
`${txn_detail__transaction__customer__type__code.toLowerCase() == 'cn' ? (txn_detail__transaction__customer__fullname.length < 14 ? txn_detail__transaction__customer__fullname : $getFirstAndLastName(txn_detail__transaction__customer__fullname)) : ''}` ||
'',
)
.replace(/\[customer\.code\]/g, txn_detail__transaction__customer__code || '')
.replace(
/\[customer\.legal_code\]/g,
txn_detail__transaction__customer__legal_code || '',
)
.replace(
/\[customer\.contact_address\]/g,
txn_detail__transaction__customer__contact_address ||
txn_detail__transaction__customer__address ||
'',
)
.replace(/\[customer\.phone\]/g, txn_detail__transaction__customer__phone || '')
.replace(/\[payment_schedule\.from_date\]/g, $formatDateVN(from_date) || '')
.replace(/\[payment_schedule\.to_date\]/g, $formatDateVN(to_date) || '')
.replace(/\[payment_schedule\.amount\]/g, $numtoString(remain_amount) || '')
.replace(
/\[payment_schedule\.amount_in_word\]/g,
$numberToVietnameseCurrency(remain_amount) || '',
)
.replace(/\[payment_schedule\.cycle\]/g, $numtoString(cycle) || '')
.replace(
/\[payment_schedule\.cycle-in-words\]/g,
`${cycle == 0 ? 'đặt cọc' : `đợt thứ ${$numberToVietnamese(cycle).toLowerCase()}`}` ||
'',
)
.replace(
/\[payment_schedule\.note\]/g,
`${cycle == 0 ? 'Dat coc' : `Dot ${cycle}`}` || '',
);
}
function quillToEmailHtml(html) {
return html
// ALIGN
.replace(/class="([^"]*?)ql-align-center([^"]*?)"/g, 'style="text-align:center;"')
.replace(/class="([^"]*?)ql-align-right([^"]*?)"/g, 'style="text-align:right;"')
.replace(/class="([^"]*?)ql-align-left([^"]*?)"/g, 'style="text-align:left;"')
.replace(/class="([^"]*?)ql-align-justify([^"]*?)"/g, 'style="text-align:justify;"')
// FONT SIZE
.replace(/ql-size-small/g, '')
.replace(/ql-size-large/g, '')
.replace(/ql-size-huge/g, '')
// REMOVE EMPTY CLASS
.replace(/class=""/g, '')
;
}
const showmodal = ref(null);
function openConfirmModal() {
showmodal.value = {
component: 'dialog/Confirm',
title: 'Xác nhận',
width: '500px',
height: '100px',
vbind: {
content: 'Bạn có đồng ý gửi thông báo hàng loạt không?',
}
}
}
async function sendEmails() {
isSending.value = true;
$snackbar('Hệ thống đang xử lý ngầm yêu cầu gửi email hàng loạt...');
const paymentScheduleData = await $getdata(
'payment_schedule',
undefined,
{
filter: filter.value,
sort: 'to_date',
values: 'penalty_paid,penalty_remain,penalty_amount,penalty_reduce,batch_date,amount_remain,paid_amount,remain_amount,code,status,txn_detail,txn_detail__transaction__product,txn_detail__transaction__product__trade_code,txn_detail__transaction__code,txn_detail__code,txn_detail__transaction__customer__code,txn_detail__transaction__customer__fullname,txn_detail__transaction__customer__email,txn_detail__transaction__customer__type__code,txn_detail__transaction__customer__legal_code,txn_detail__transaction__customer__contact_address,txn_detail__transaction__customer__address,txn_detail__transaction__customer__phone,txn_detail__transaction__policy__code,txn_detail__phase__name,type__name,from_date,to_date,amount,cycle,cycle_days,status__name,detail,entry',
}
);
const emailTemplate = await $getdata(
'emailtemplate',
{ id: activeDateFilter.value.emailTemplate },
undefined,
true
);
let message = emailTemplate.content.content;
contents.value = paymentScheduleData.map(paymentSchedule => {
message = message.replace(/<div[^>]*>\s*<img[^>]*img\.vietqr\.io[^>]*>\s*<\/div>/g, '');
const transfer = {
bank: {
code: 'MB',
name: 'MB Bank',
},
account: {
number: '146768686868',
name: 'CONG TY CO PHAN BAT DONG SAN UTOPIA',
},
content: 'Thanh toán đơn #xyz',
};
transfer.content = buildContentPayment(paymentSchedule);
const params = new URLSearchParams({
addInfo: transfer.content,
accountName: transfer.account.name,
});
const qrImageUrl = `https://img.vietqr.io/image/${transfer.bank.code}-${transfer.account.number}-print.png?${params.toString()}`;
message = `
${message.trim()}
${buildQrHtml(qrImageUrl)}
`;
return {
...emailTemplate.content,
content: undefined,
message: replaceTemplateVars(message, paymentSchedule),
}
});
await forEachAsync(contents.value, async (bigContent, i) => {
const { imageUrl, keyword, message, subject } = bigContent;
const tempEm = {
content: toRaw(bigContent),
previewMode: true,
};
// ===== QUILL → HTML EMAIL (INLINE STYLE) =====
tempEm.content.message = quillToEmailHtml(message);
let emailHtml = await render(Template1, tempEm);
// If no image URL provided, remove image section from HTML
if ((imageUrl ?? '').trim() === '') {
emailHtml = emailHtml
.replace(/<tr\s+class=["']header-row["'][^>]*>[\s\S]*?<\/tr>/gi, '')
.replace(/\n\s*\n\s*\n/g, '\n\n');
}
// Replace keywords in HTML
let finalEmailHtml = emailHtml;
if (keyword && keyword.length > 0) {
keyword.forEach(({ keyword, value }) => {
if (keyword && value) {
const regex = new RegExp(`\\{\\{${keyword}\\}\\}`, 'g');
finalEmailHtml = finalEmailHtml.replace(regex, value);
}
});
}
const response = await $insertapi(
'sendemail',
{
to: paymentScheduleData[i].txn_detail__transaction__customer__email,
content: finalEmailHtml,
subject: replaceTemplateVars(subject, paymentScheduleData[i]) || 'Thông báo từ Utopia Villas & Resort',
},
undefined,
false,
);
if (response !== null) {
await $insertapi('productnote', {
ref: paymentScheduleData[i].txn_detail__transaction__product,
user: $store.login.id,
detail: `Đã gửi email thông báo nhắc nợ cho sản phẩm ${paymentScheduleData[i].txn_detail__transaction__product__trade_code} vào lúc ${$dayjs().format('HH:mm ngày DD/MM/YYYY')}.`
}, undefined, false);
}
})
setTimeout(() => {
$snackbar('Thông báo đã được gửi thành công đến các khách hàng.');
isSending.value = false;
}, 1000);
}
watch(filter, () => {
key.value += 1;
}, { deep: true })
</script>
<template>
<div class="is-flex is-justify-content-space-between is-align-content-center mb-4">
<div class="buttons m-0">
<p>Đến hạn:</p>
<button
v-for="payable in payables"
:key="payable.id"
@click="setDateFilter(payable.detail)"
:class="['button', { 'is-primary': isEqual(activeDateFilter, payable.detail) }]"
>
{{ payable.detail.lookup === 'lte' ? '≤' : '>' }} {{ payable.detail.time }} ngày
</button>
<button
v-if="activeDateFilter"
@click="resetDateFilter()"
class="button is-white"
>
Xoá lọc
</button>
</div>
<button
v-if="activeDateFilter"
@click="openConfirmModal()"
:class="['button', 'is-light', { 'is-loading': isSending }]"
>
Gửi thông báo
</button>
</div>
<DataView
:key="key"
v-bind="{
pagename: 'payment-schedule-due',
api: 'payment_schedule',
setting: 'payment-schedule-debt-due',
realtime: { time: '5', update: 'true' },
params: {
filter,
sort: 'to_date',
values: 'penalty_paid,penalty_remain,penalty_amount,penalty_reduce,batch_date,ovd_days,amount_remain,paid_amount,remain_amount,code,status,txn_detail,txn_detail__transaction__product,txn_detail__transaction__product__trade_code,txn_detail__transaction__code,txn_detail__code,txn_detail__transaction__customer__code,txn_detail__transaction__customer__fullname,txn_detail__transaction__customer__email,txn_detail__transaction__policy__code,txn_detail__phase__name,type__name,from_date,to_date,amount,cycle,cycle_days,status__name,detail,entry',
}
}" />
<Modal
v-if="showmodal"
v-bind="showmodal"
@confirm="sendEmails()"
@close="showmodal = undefined"
/>
<!-- <div class="is-flex is-gap-1">
// debug
<Template1
v-if="contents"
v-for="content in contents"
:content="content"
previewMode
/>
</div> -->
</template>

View File

@@ -0,0 +1,363 @@
<script setup>
import Template1 from '@/lib/email/templates/Template1.vue';
import { render } from '@vue-email/render';
import { forEachAsync, isEqual } from 'es-toolkit';
const {
$dayjs,
$getdata,
$insertapi,
$numtoString,
$numberToVietnamese,
$numberToVietnameseCurrency,
$formatDateVN,
$getFirstAndLastName,
$snackbar,
$store,
} = useNuxtApp();
const payables = ref(null);
const defaultFilter = {
status: 1,
to_date__lt: $dayjs().format('YYYY-MM-DD'),
}
const filter = ref(defaultFilter);
const activeDateFilter = ref(null);
const key = ref(0);
function setDateFilter(detail) {
activeDateFilter.value = isEqual(activeDateFilter.value, detail) ? null : detail;
}
function resetDateFilter() {
activeDateFilter.value = null;
}
onMounted(async () => {
const payablesData = await $getdata('bizsetting', undefined, { filter: { classify: 'overduepayables' }, sort: 'index' });
payables.value = payablesData;
});
watch(activeDateFilter, (val) => {
if (!val) {
filter.value = defaultFilter;
contents.value = null;
} else {
const cutoffDate = $dayjs().subtract(val.time, 'day').format('YYYY-MM-DD');
const filterField = `to_date__${val.lookup === 'lte' ? 'gt' :
'lte'}`;
filter.value = {
...defaultFilter,
[filterField]: cutoffDate,
}
}
}, { deep: true })
const contents = ref(null);
const isSending = ref(false);
function sanitizeContentPayment(text, maxLength = 80) {
if (!text) return '';
return text
.normalize('NFD') // bỏ dấu tiếng Việt
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-zA-Z0-9 _-]/g, '') // bỏ ký tự lạ
.replace(/\s+/g, ' ')
.trim()
.slice(0, maxLength);
}
const buildQrHtml = (url) => `
<div style="text-align: center; margin-top: 16px">
<img
src="${url}"
alt="VietQR"
width="500"
style="display: inline-block"
/>
</div>
`;
const buildContentPayment = (data) => {
const {
txn_detail__transaction__customer__type__code: customerType,
txn_detail__transaction__customer__code: customerCode,
txn_detail__transaction__customer__fullname: customerName,
txn_detail__transaction__product__trade_code: productCode,
cycle,
} = data;
if (customerType.toLowerCase() === 'cn') {
if (customerName.length < 14) {
return `${productCode} ${customerCode} ${customerName} TT ${cycle == 0 ? 'Dat Coc' : `Dot ${cycle}`}`;
} else {
return `${productCode} ${customerCode} ${$getFirstAndLastName(customerName)} TT ${cycle == 0 ? 'Dat Coc' : `Dot ${cycle}`}`;
}
} else {
return `${productCode} ${customerCode} TT ${cycle == 0 ? 'Dat Coc' : `Dot ${cycle}`}`;
}
};
function replaceTemplateVars(html, paymentScheduleItem) {
const {
txn_detail__transaction__product__trade_code,
txn_detail__transaction__customer__code,
txn_detail__transaction__customer__fullname,
txn_detail__transaction__customer__legal_code,
txn_detail__transaction__customer__type__code,
txn_detail__transaction__customer__contact_address,
txn_detail__transaction__customer__address,
txn_detail__transaction__customer__phone,
from_date,
to_date,
remain_amount,
cycle
} = paymentScheduleItem;
return html
.replace(/\[day]/g, String(new Date().getDate()).padStart(2, '0') || '')
.replace(/\[month]/g, String(new Date().getMonth() + 1).padStart(2, '0') || '')
.replace(/\[year]/g, new Date().getFullYear() || '')
.replace(/\[product\.trade_code\]/g, txn_detail__transaction__product__trade_code)
.replace(/\[product\.trade_code_payment\]/g, sanitizeContentPayment(txn_detail__transaction__product__trade_code))
.replace(/\[customer\.fullname\]/g, txn_detail__transaction__customer__fullname)
.replace(
/\[customer\.name\]/g,
`${txn_detail__transaction__customer__type__code.toLowerCase() == 'cn' ? (txn_detail__transaction__customer__fullname.length < 14 ? txn_detail__transaction__customer__fullname : $getFirstAndLastName(txn_detail__transaction__customer__fullname)) : ''}` ||
'',
)
.replace(/\[customer\.code\]/g, txn_detail__transaction__customer__code || '')
.replace(
/\[customer\.legal_code\]/g,
txn_detail__transaction__customer__legal_code || '',
)
.replace(
/\[customer\.contact_address\]/g,
txn_detail__transaction__customer__contact_address ||
txn_detail__transaction__customer__address ||
'',
)
.replace(/\[customer\.phone\]/g, txn_detail__transaction__customer__phone || '')
.replace(/\[payment_schedule\.from_date\]/g, $formatDateVN(from_date) || '')
.replace(/\[payment_schedule\.to_date\]/g, $formatDateVN(to_date) || '')
.replace(/\[payment_schedule\.amount\]/g, $numtoString(remain_amount) || '')
.replace(
/\[payment_schedule\.amount_in_word\]/g,
$numberToVietnameseCurrency(remain_amount) || '',
)
.replace(/\[payment_schedule\.cycle\]/g, $numtoString(cycle) || '')
.replace(
/\[payment_schedule\.cycle-in-words\]/g,
`${cycle == 0 ? 'đặt cọc' : `đợt thứ ${$numberToVietnamese(cycle).toLowerCase()}`}` ||
'',
)
.replace(
/\[payment_schedule\.note\]/g,
`${cycle == 0 ? 'Dat coc' : `Dot ${cycle}`}` || '',
);
}
function quillToEmailHtml(html) {
return html
// ALIGN
.replace(/class="([^"]*?)ql-align-center([^"]*?)"/g, 'style="text-align:center;"')
.replace(/class="([^"]*?)ql-align-right([^"]*?)"/g, 'style="text-align:right;"')
.replace(/class="([^"]*?)ql-align-left([^"]*?)"/g, 'style="text-align:left;"')
.replace(/class="([^"]*?)ql-align-justify([^"]*?)"/g, 'style="text-align:justify;"')
// FONT SIZE
.replace(/ql-size-small/g, '')
.replace(/ql-size-large/g, '')
.replace(/ql-size-huge/g, '')
// REMOVE EMPTY CLASS
.replace(/class=""/g, '')
;
}
const showmodal = ref(null);
function openConfirmModal() {
showmodal.value = {
component: 'dialog/Confirm',
title: 'Xác nhận',
width: '500px',
height: '100px',
vbind: {
content: 'Bạn có đồng ý gửi thông báo hàng loạt không?',
}
}
}
async function sendEmails() {
isSending.value = true;
$snackbar('Hệ thống đang xử lý ngầm yêu cầu gửi email hàng loạt...');
const paymentScheduleData = await $getdata(
'payment_schedule',
undefined,
{
filter: filter.value,
sort: 'to_date',
values: 'penalty_paid,penalty_remain,penalty_amount,penalty_reduce,batch_date,amount_remain,paid_amount,remain_amount,code,status,txn_detail,txn_detail__transaction__product,txn_detail__transaction__product__trade_code,txn_detail__transaction__code,txn_detail__code,txn_detail__transaction__customer__code,txn_detail__transaction__customer__fullname,txn_detail__transaction__customer__email,txn_detail__transaction__customer__type__code,txn_detail__transaction__customer__legal_code,txn_detail__transaction__customer__contact_address,txn_detail__transaction__customer__address,txn_detail__transaction__customer__phone,txn_detail__transaction__policy__code,txn_detail__phase__name,type__name,from_date,to_date,amount,cycle,cycle_days,status__name,detail,entry',
}
);
const emailTemplate = await $getdata(
'emailtemplate',
{ id: activeDateFilter.value.emailTemplate },
undefined,
true
);
let message = emailTemplate.content.content;
contents.value = paymentScheduleData.map(paymentSchedule => {
message = message.replace(/<div[^>]*>\s*<img[^>]*img\.vietqr\.io[^>]*>\s*<\/div>/g, '');
const transfer = {
bank: {
code: 'MB',
name: 'MB Bank',
},
account: {
number: '146768686868',
name: 'CONG TY CO PHAN BAT DONG SAN UTOPIA',
},
content: 'Thanh toán đơn #xyz',
};
transfer.content = buildContentPayment(paymentSchedule);
const params = new URLSearchParams({
addInfo: transfer.content,
accountName: transfer.account.name,
});
const qrImageUrl = `https://img.vietqr.io/image/${transfer.bank.code}-${transfer.account.number}-print.png?${params.toString()}`;
message = `
${message.trim()}
${buildQrHtml(qrImageUrl)}
`;
return {
...emailTemplate.content,
content: undefined,
message: replaceTemplateVars(message, paymentSchedule),
}
});
await forEachAsync(contents.value, async (bigContent, i) => {
const { imageUrl, keyword, message, subject } = bigContent;
const tempEm = {
content: toRaw(bigContent),
previewMode: true,
};
// ===== QUILL → HTML EMAIL (INLINE STYLE) =====
tempEm.content.message = quillToEmailHtml(message);
let emailHtml = await render(Template1, tempEm);
// If no image URL provided, remove image section from HTML
if ((imageUrl ?? '').trim() === '') {
emailHtml = emailHtml
.replace(/<tr\s+class=["']header-row["'][^>]*>[\s\S]*?<\/tr>/gi, '')
.replace(/\n\s*\n\s*\n/g, '\n\n');
}
// Replace keywords in HTML
let finalEmailHtml = emailHtml;
if (keyword && keyword.length > 0) {
keyword.forEach(({ keyword, value }) => {
if (keyword && value) {
const regex = new RegExp(`\\{\\{${keyword}\\}\\}`, 'g');
finalEmailHtml = finalEmailHtml.replace(regex, value);
}
});
}
const response = await $insertapi(
'sendemail',
{
to: paymentScheduleData[i].txn_detail__transaction__customer__email,
content: finalEmailHtml,
subject: replaceTemplateVars(subject, paymentScheduleData[i]) || 'Thông báo từ Utopia Villas & Resort',
},
undefined,
false,
);
if (response !== null) {
await $insertapi('productnote', {
ref: paymentScheduleData[i].txn_detail__transaction__product,
user: $store.login.id,
detail: `Đã gửi email thông báo quá hạn cho sản phẩm ${paymentScheduleData[i].txn_detail__transaction__product__trade_code} vào lúc ${$dayjs().format('HH:mm ngày DD/MM/YYYY')}.`
}, undefined, false);
}
})
setTimeout(() => {
$snackbar('Thông báo đã được gửi thành công đến các khách hàng.');
isSending.value = false;
}, 1000);
}
watch(filter, () => {
key.value += 1;
}, { deep: true })
</script>
<template>
<div class="is-flex is-justify-content-space-between is-align-content-center mb-4">
<div class="buttons m-0">
<p>Quá hạn:</p>
<button
v-for="payable in payables"
:key="payable.id"
@click="setDateFilter(payable.detail)"
:class="['button', { 'is-primary': isEqual(activeDateFilter, payable.detail) }]"
>
{{ payable.detail.lookup === 'lte' ? '≤' : '>' }} {{ payable.detail.time }} ngày
</button>
<button
v-if="activeDateFilter"
@click="resetDateFilter()"
class="button is-white"
>
Xoá lọc
</button>
</div>
<button
v-if="activeDateFilter"
@click="openConfirmModal()"
:class="['button', 'is-light', { 'is-loading': isSending }]"
>
Gửi thông báo
</button>
</div>
<DataView
:key="key"
v-bind="{
pagename: 'payment-schedule-overdue',
api: 'payment_schedule',
setting: 'payment-schedule-debt-overdue',
realtime: { time: '5', update: 'true' },
params: {
filter,
sort: 'to_date',
values: 'penalty_paid,penalty_remain,penalty_amount,penalty_reduce,batch_date,ovd_days,amount_remain,paid_amount,remain_amount,code,status,txn_detail,txn_detail__transaction__product,txn_detail__transaction__product__trade_code,txn_detail__transaction__code,txn_detail__code,txn_detail__transaction__customer__code,txn_detail__transaction__customer__fullname,txn_detail__transaction__customer__email,txn_detail__transaction__policy__code,txn_detail__phase__name,type__name,from_date,to_date,amount,cycle,cycle_days,status__name,detail,entry',
}
}" />
<Modal
v-if="showmodal"
v-bind="showmodal"
@confirm="sendEmails()"
@close="showmodal = undefined"
/>
<!-- <div class="is-flex is-gap-1">
// debug
<Template1
v-if="contents"
v-for="content in contents"
:content="content"
previewMode
/>
</div> -->
</template>

View File

@@ -0,0 +1,69 @@
<template>
<div>
<Caption v-bind="{title: 'Nhập mã duyệt'}"></Caption>
<div class="field is-grouped mt-5">
<div class="control mr-5" v-for="v in [1,2,3,4]">
<input class="input is-dark" style="font-size: 18px; max-width: 40px; font-weight:bold; text-align: center;" type="password"
maxlength="1" :id="`input${v}`" v-model="data[v]" @keyup="changeNext(v)" />
</div>
</div>
<div class="mt-4">
<a @click="reset()">Nhập lại</a>
</div>
</div>
</template>
<script>
export default {
data() {
return {
data: {},
code: undefined
}
},
async created() {
if(!this.approvalcode && this.$store.state.login.approval_code) this.approvalcode = this.$store.state.login.approval_code
if(!this.approvalcode) {
let user = await this.$getdata('user', {id: this.$store.state.login.id}, undefined, true)
this.approvalcode = user.approval_code
}
},
mounted() {
this.reset()
},
computed: {
approvalcode: {
get: function() {return this.$store.state['approvalcode']},
set: function(val) {this.$store.commit('updateStore', {name: 'approvalcode', data: val})}
}
},
methods: {
checkError() {
if(Object.keys(this.data).length<4) return true
let code = ''
for (const [key, value] of Object.entries(this.data)) {
if(!this.$empty(value)) code += value.toString()
}
if(this.$empty(code) || !this.$isNumber(code)) return true
this.code = code
return false
},
checkValid() {
if(this.checkError()) return this.$snackbar(' phê duyệt không hợp lệ')
if(this.code!==this.approvalcode) return this.$snackbar(' phê duyệt không chính xác')
this.$emit('modalevent', {name: 'approvalcode', data: this.code})
this.$emit('close')
},
changeNext(v) {
if(this.$empty(this.data[v])) return
else if(v===4) return this.checkValid()
let doc = document.getElementById(`input${v+1}`)
if(doc) doc.focus()
},
reset() {
this.data = {}
let doc = document.getElementById(`input1`)
if(doc) doc.focus()
}
}
}
</script>

View File

@@ -0,0 +1,29 @@
<template>
<div>
<p v-html="content"></p>
<p class="border-bottom mt-3 mb-5"></p>
<div class="field is-grouped">
<div class="control is-expanded">
<button class="button is-primary has-text-white" @click="confirm()">Đồng ý</button>
<button class="button is-dark ml-5" @click="cancel()">Hủy bỏ</button>
</div>
<div class="control" v-if="duration">
<CountDown v-bind="{duration: duration}" @close="cancel()"></CountDown>
</div>
</div>
</div>
</template>
<script>
export default {
props: ['content', 'duration'],
methods: {
cancel() {
this.$emit('close')
},
confirm() {
this.$emit('modalevent', {name: 'confirm'})
this.cancel()
}
}
}
</script>

View File

@@ -0,0 +1,75 @@
<template>
<div id="countdown">
<div id="countdown-number"></div>
<svg><circle r="18" cx="20" cy="20" color="red"></circle></svg>
</div>
</template>
<script>
export default {
props: ['duration'],
data() {
return {
timer: undefined,
countdown: this.duration || 10
}
},
mounted() {
var countdownNumberEl = document.getElementById('countdown-number')
countdownNumberEl.textContent = this.countdown;
this.timer = setInterval(()=>this.startCount(), 1000)
},
beforeDestroy() {
clearInterval(this.timer)
},
methods: {
startCount() {
this.countdown -= 1
var countdownNumberEl = document.getElementById('countdown-number')
countdownNumberEl.textContent = this.countdown;
if(this.countdown===0) {
clearInterval(this.timer)
this.$emit('close')
}
}
}
}
</script>
<style scoped>
#countdown {
position: relative;
margin: auto;
height: 40px;
width: 40px;
text-align: center;
}
#countdown-number {
color: black;
display: inline-block;
line-height: 40px;
}
:deep(svg) {
position: absolute;
top: 0;
right: 0;
width: 40px;
height: 40px;
transform: rotateY(-180deg) rotateZ(-90deg);
}
:deep(svg circle) {
stroke-dasharray: 113px;
stroke-dashoffset: 0px;
stroke-linecap: round;
stroke-width: 2px;
stroke: black;
fill: none;
animation: countdown 10s linear infinite forwards;
}
@keyframes countdown {
from {
stroke-dashoffset: 0px;
}
to {
stroke-dashoffset: 113px;
}
}
</style>

View File

@@ -0,0 +1,47 @@
<template>
<div>
<p v-html="content"></p>
<p class="border-bottom mt-4 mb-5"></p>
<div class="field is-grouped">
<div class="control is-expanded">
<button class="button is-danger" @click="remove()">Đồng ý</button>
<button class="button is-dark ml-5" @click="cancel()">Hủy bỏ</button>
</div>
<div class="control" v-if="duration">
<CountDown v-bind="{duration: duration}" @close="cancel()"></CountDown>
</div>
</div>
</div>
</template>
<script>
export default {
props: ['content', 'duration', 'vbind', 'setdeleted'],
methods: {
cancel() {
this.$emit('close')
},
async remove() {
let pagename = this.vbind.pagename
let pagedata = this.$store.state[pagename]
let name = pagedata.origin_api.name || this.vbind.api
let id = this.vbind.row.id
let result
if(this.setdeleted) {
let record = await this.$getdata(name, {id: id}, undefined, true)
record.deleted = 1
result = await this.$updateapi(name, record)
} else result = await this.$deleteapi(name, id)
if(result==='error') return this.$dialog('Đã xảy ra lỗi, xóa dữ liệu thất bại', 'Lỗi', 'Error')
this.$snackbar('Dữ liệu đã được xoá khỏi hệ thống', undefined, 'Success')
let arr = Array.isArray(id)? id : [{id: id}]
let copy = this.$copy(this.$store.state[pagename].data)
arr.map(x=>{
let index = copy.findIndex(v=>v.id===x.id)
index>=0? this.$delete(copy,index) : false
})
this.$store.commit('updateState', {name: pagename, key: 'update', data: {data: copy}})
this.cancel()
}
}
}
</script>

View File

@@ -0,0 +1,31 @@
<template>
<div>
<div class="field is-grouped">
<div class="control is-expanded pr-3" v-html="content"></div>
<div class="control">
<SvgIcon v-bind="{name: 'error.svg', type: 'danger', size: 24}"></SvgIcon>
</div>
</div>
<p class="border-bottom mt-3 mb-5"></p>
<div class="field is-grouped">
<div class="control is-expanded">
<button class="button is-danger has-text-white" @click="cancel()">Đóng</button>
</div>
<div class="control" v-if="duration">
<CountDown v-bind="{duration: duration}" @close="cancel()"></CountDown>
</div>
</div>
</div>
</template>
<script setup>
import { useStore } from '@/stores/index'
const store = useStore()
var props = defineProps({
content: String,
duration: Number
})
function cancel() {
this.$emit('close')
}
</script>

View File

@@ -0,0 +1,24 @@
<template>
<div>
<p v-html="content"></p>
<p class="border-bottom mt-3 mb-5"></p>
<div class="field is-grouped">
<div class="control is-expanded">
<button class="button is-dark" @click="cancel()">Đóng</button>
</div>
<div class="control" v-if="duration">
<CountDown v-bind="{duration: duration}" @close="cancel()"></CountDown>
</div>
</div>
</div>
</template>
<script>
export default {
props: ['content', 'duration'],
methods: {
cancel() {
this.$emit('close')
}
}
}
</script>

View File

@@ -0,0 +1,110 @@
<template>
<div class="modal-card">
<header class="modal-card-head" :class="headerClass">
<p class="modal-card-title has-text-white">
<span class="icon-text">
<span class="icon">
<SvgIcon :name="iconName" type="white" :size="24" />
</span>
<span>{{ title }}</span>
</span>
</p>
</header>
<section class="modal-card-body">
<div class="field">
<label class="label">{{ label }}</label>
<div class="control">
<textarea class="textarea" v-model="note" :placeholder="placeholder"></textarea>
</div>
<p v-if="error" class="help is-danger">{{ error }}</p>
</div>
</section>
<footer class="modal-card-foot is-justify-content-flex-end">
<button class="button" @click="$emit('close')">{{ cancelText }}</button>
<button class="button" :class="buttonClass" @click="confirm">{{ confirmText }}</button>
</footer>
</div>
</template>
<script>
import { useStore } from "@/stores/index";
export default {
props: {
title: {
type: String,
default: 'Nhập nội dung'
},
label: {
type: String,
default: 'Ghi chú'
},
placeholder: {
type: String,
default: 'Nhập nội dung...'
},
type: {
type: String,
default: 'primary', // primary, warning, danger
},
confirmText: {
type: String,
default: 'Xác nhận'
},
cancelText: {
type: String,
default: 'Hủy'
}
},
emits: ['close', 'modalevent'],
setup() {
const store = useStore();
return { store };
},
data() {
return {
note: '',
error: ''
};
},
computed: {
isVietnamese() {
return this.store.lang === 'vi';
},
headerClass() {
const colorMap = {
primary: 'has-background-primary',
warning: 'has-background-warning',
danger: 'has-background-danger',
};
return colorMap[this.type] || 'has-background-primary';
},
buttonClass() {
const colorMap = {
primary: 'is-primary',
warning: 'is-warning',
danger: 'is-danger',
};
return colorMap[this.type] || 'is-primary';
},
iconName() {
const iconMap = {
primary: 'edit.svg',
warning: 'warning.svg',
danger: 'alert.svg',
};
return iconMap[this.type] || 'edit.svg';
}
},
methods: {
confirm() {
if (!this.note.trim()) {
this.error = this.isVietnamese ? 'Nội dung không được để trống.' : 'Content cannot be empty.';
return;
}
this.$emit('modalevent', { name: 'noteConfirm', data: { note: this.note } });
this.$emit('close');
}
}
};
</script>

View File

@@ -0,0 +1,29 @@
<template>
<div>
<div class="field is-grouped">
<div class="control is-expanded pr-3" v-html="content"></div>
<div class="control">
<SvgIcon v-bind="{name: 'check2.svg', type: 'primary', size: 24}"></SvgIcon>
</div>
</div>
<p class="border-bottom mt-3 mb-5"></p>
<div class="field is-grouped">
<div class="control is-expanded">
<button class="button is-primary" @click="cancel()">Đóng</button>
</div>
<div class="control" v-if="duration">
<CountDown v-bind="{duration: duration}" @close="cancel()"></CountDown>
</div>
</div>
</div>
</template>
<script>
export default {
props: ['content', 'duration'],
methods: {
cancel() {
this.$emit('close')
}
}
}
</script>

View File

@@ -0,0 +1,178 @@
<template>
<div class="columns mx-0">
<div class="column is-narrow px-0 mx-0">
<div style="width: 200px">
<div class="py-1" v-for="v in array">
<a
:class="(current ? current.code === v.code : false) ? 'has-text-primary has-text-weight-bold' : ''"
@click="changeTab(v)"
>{{ v.name }}</a
>
</div>
</div>
</div>
<div class="column">
<div class="fsb-20 mb-3" v-if="current">{{ current.name }}</div>
<DataView v-bind="current.vbind" v-if="current && current.typeView === 'table'"></DataView>
<component v-if="current && current.typeView === 'component'" :is="current.component" v-bind="current.vbind" />
</div>
</div>
</template>
<script setup>
var array = [
{
code: "transactionphase",
name: "Giai đoạn giao dịch",
typeView: "table",
vbind: {
api: "transactionphase",
setting: "transaction-phase",
pagename: "pagedata99",
timeopt: 36000,
modal: {
component: "parameter/TransactionPhase",
title: "Transaction phase",
height: "400px",
vbind: { api: "transactionphase" },
},
},
},
{
code: "cart",
name: "Danh sách giỏ hàng",
typeView: "table",
vbind: {
api: "cart",
setting: "parameter-fields-cart",
pagename: "parameter-fields-cart",
timeopt: 36000,
modal: {
component: "parameter/CodeName",
title: "Giỏ hàng",
height: "400px",
vbind: { api: "cart" },
},
},
},
{
code: "documenttype",
name: "Loại tài liệu",
typeView: "table",
vbind: {
api: "documenttype",
setting: "parameter-fields",
pagename: "pagedata99",
timeopt: 36000,
modal: {
component: "parameter/CodeName",
title: "Document type",
height: "400px",
vbind: { api: "documenttype" },
},
},
},
{
code: "discounttype",
name: "Danh sách chiết khấu",
typeView: "table",
vbind: {
api: "discounttype",
setting: "parameter-discount",
pagename: "tableDiscountType",
timeopt: 36000,
modal: {
component: "parameter/DiscountType",
title: "Thông tin chiết khấu",
height: "400px",
vbind: { api: "discounttype" },
},
},
},
{
code: "gifttype",
name: "Danh sách quà tặng",
typeView: "table",
vbind: {
api: "gift",
setting: "parameter-gift",
pagename: "tableDiscountType",
timeopt: 36000,
modal: {
component: "parameter/GiftType",
title: "Thông tin quà tặng",
height: "400px",
vbind: { api: "gift" },
},
},
},
{
code: "DuePayables",
name: "Lịch công nợ đến hạn",
typeView: "table",
vbind: {
api: "bizsetting",
params: {
filter: {
category: "system",
classify: "duepayables",
},
},
setting: "parameter-payable-schedule",
pagename: "tablePayableSchedule",
timeopt: 36000,
modal: {
component: "parameter/DuePayables",
title: "Thông tin lịch công nợ đến hạn",
height: "400px",
vbind: { api: "bizsetting" },
},
},
},
{
code: "OverduePayables",
name: "Lịch công nợ quá hạn",
typeView: "table",
vbind: {
api: "bizsetting",
params: {
filter: {
category: "system",
classify: "overduepayables",
},
},
setting: "parameter-overdue-payables",
pagename: "tableOverduePayables",
timeopt: 36000,
modal: {
component: "parameter/OverduePayables",
title: "Thông tin lịch công nợ quá hạn",
height: "400px",
vbind: { api: "bizsetting" },
},
},
},
// {
// code: "allocationRules",
// name: "Quy tắc phân bổ",
// typeView: "component",
// component: defineAsyncComponent(() => import("@/components/parameter/AllocationRules.vue")),
// vbind: {
// api: "common",
// setting: "parameter-gift",
// pagename: "tableDiscountType",
// timeopt: 36000,
// modal: {
// component: "parameter/AllocationRules",
// title: "Thông tin quà tặng",
// height: "400px",
// vbind: { api: "gift" },
// },
// },
// },
];
var current = ref(array[0]);
function changeTab(v) {
current.value = null;
setTimeout(() => (current.value = v));
}
</script>

View File

@@ -0,0 +1,5 @@
export const apiUrl = "https://api.utopia.com.vn/data";
export const putApiUrl = "https://api.utopia.com.vn/data-detail";
export const sendEmailUrl = "https://api.utopia.com.vn/send-email";
export const logoUrl = "https://bigdatatech.vn/logo.png";
export const imageUrl = "https://api.bigdatatech.vn/static/files/20251113051227-1.png";

View File

@@ -0,0 +1,471 @@
<template>
<div class="container is-fluid px-0" style="overflow: hidden">
<!-- 95px is height of nav + breadcrumb -->
<div class="columns m-0" style="height: calc(100vh - 95px)">
<!-- Form Section -->
<div class="column is-5 pb-5" style="
overflow-y: scroll;
border-right: 1px solid #dbdbdb;
">
<label class="label">
<span class="icon-text">
<span>Chọn mẫu</span>
</span>
</label>
<div class="level mb-4 mt-2 pr-4">
<!-- Template Selector -->
<div class="level-left" style="flex: 1">
<div class="level-item" style="flex: 1">
<div class="field" style="width: 100%">
<div class="control">
<div class="select is-fullwidth">
<select v-model="selectedValue" @change="handleTemplateChange">
<option value="defaultTemplate">Mặc định</option>
<option
v-for="template in dataTemplate"
:key="template.id"
:value="template.name"
>
{{ template.name }}
</option>
</select>
</div>
</div>
</div>
</div>
</div>
<!-- Action Buttons -->
<div v-if="$getEditRights()" class="level-right">
<div class="level-item">
<div class="buttons">
<button class="button is-light" @click="handleOpenModal">
<span class="icon">
<SvgIcon v-bind="{ name: 'save.svg', type: 'primary', size: 18 }"></SvgIcon>
</span>
</button>
<button
v-if="selectedTemplateId"
class="button is-danger is-outlined"
@click="showDeleteDialog = true"
:disabled="loading"
>
<span class="icon">
<SvgIcon v-bind="{ name: 'trash.svg', type: 'white', size: 18 }"></SvgIcon>
</span>
</button>
</div>
</div>
</div>
</div>
<!-- Tabbed Interface -->
<div class="tabs mb-3">
<ul>
<li :class="{ 'is-active': activeTab === 'content' }">
<a @click="activeTab = 'content'">Content</a>
</li>
<template v-if="$getEditRights()">
<li :class="{ 'is-active': activeTab === 'mappings' }">
<a @click="activeTab = 'mappings'">Mappings</a>
</li>
<li :class="{ 'is-active': activeTab === 'automation' }">
<a @click="activeTab = 'automation'">Automation</a>
</li>
</template>
</ul>
</div>
<!-- Form Component based on tab -->
<div v-show="activeTab === 'content'">
<component
:is="currentFormComponent"
:key="formKey"
:initial-data="emailFormData"
@data-change="handleChangeData"
/>
</div>
<div v-if="activeTab === 'mappings'">
<MappingConfigurator :mappings="formData.mappings" @update:mappings="updateMappings" />
</div>
<div v-if="activeTab === 'automation'">
<JobConfigurator :template-id="selectedTemplateId" />
</div>
</div>
<!-- Preview Section -->
<div class="column is-7 p-0" style="overflow-y: scroll">
<component :is="currentTemplateComponent" v-bind="templateProps" style="height: 100%" />
</div>
</div>
<!-- Delete Confirmation Modal -->
<div v-if="showDeleteDialog" class="modal is-active">
<div class="modal-background" @click="showDeleteDialog = false"></div>
<div class="modal-card">
<header class="modal-card-head p-4">
<p class="modal-card-title">Xác nhận xóa mẫu email</p>
<button class="delete" @click="showDeleteDialog = false"></button>
</header>
<section class="modal-card-body p-4">
<p class="mb-4">Bạn chắc chắn muốn xóa mẫu email này không? Hành động này không thể hoàn tác.</p>
<p class="has-text-weight-bold has-text-danger">Mẫu: {{ dataTemplateSelected?.name }}</p>
</section>
<footer class="modal-card-foot p-4">
<button class="button mr-2" @click="showDeleteDialog = false" :disabled="loading">Hủy</button>
<button
class="button is-danger has-text-white"
@click="handleDeleteTemplate"
:disabled="loading"
:class="{ 'is-loading': loading }"
>
{{ loading ? "Đang xóa..." : "Xóa" }}
</button>
</footer>
</div>
</div>
</div>
<Modal @close="showmodal = undefined" v-bind="showmodal" v-if="showmodal"></Modal>
</template>
<script setup lang="ts">
import { useNuxtApp } from "nuxt/app";
import { ref, computed, onMounted, watch, markRaw } from "vue";
import EmailForm1 from "./forms/EmailForm1.vue";
import Template1 from "@/lib/email/templates/Template1.vue";
import Modal from "@/components/Modal.vue";
import MappingConfigurator from "@/components/marketing/email/MappingConfigurator.vue";
import JobConfigurator from "@/components/marketing/email/JobConfigurator.vue";
const nuxtApp = useNuxtApp();
const $snackbar = nuxtApp.$snackbar as (message?: string) => void;
const $getdata = nuxtApp.$getdata as (name: string) => Promise<DataTemplate[]>;
const $deleteapi = nuxtApp.$deleteapi as (name: string, id: any) => Promise<DataTemplate[]>;
const $getEditRights = nuxtApp.$getEditRights as () => boolean;
const showmodal = ref<any>();
const activeTab = ref("content");
const defaultTemplate = 'defaultTemplate';
// Types
interface FormContent {
receiver: string;
subject: string;
content: string; // This is the email body
imageUrl: string | null;
linkUrl: string[];
textLinkUrl: string[];
keyword: (string | { keyword: string; value: string })[];
html: string;
}
interface FormData {
name?: string;
id?: number;
template: string;
content: FormContent;
mappings: any[];
}
interface DataTemplate {
id: number;
name: string;
content: Record<string, any> | string;
mappings?: any[];
}
// Reactive state
const formData = ref<FormData>({
name: "",
id: undefined,
template: defaultTemplate,
content: {
receiver: "",
subject: "",
content: "",
imageUrl: null,
linkUrl: [""],
textLinkUrl: [""],
keyword: [{ keyword: "", value: "" }],
html: "",
},
mappings: [],
});
const dataTemplate = ref<DataTemplate[] | null>(null);
const dataTemplateSelected = ref<DataTemplate | null>(null);
const originalLoadedTemplate = ref<DataTemplate | null>(null); // For diffing on save
const editMode = ref(false);
const formKey = ref(0);
const selectedValue = ref(defaultTemplate);
const loading = ref(false);
const selectedTemplateId = ref<number | null>(null);
const showDeleteDialog = ref(false);
// Computed properties
const emailFormData = computed(() => ({
id: formData.value.id,
name: formData.value.name || "",
template: formData.value.template,
content: formData.value.content,
}));
const templateProps = computed(() => ({
content: {
subject: formData.value.content.subject || "Thông báo mới",
message: formData.value.content.content || "Bạn có một thông báo mới.",
imageUrl: formData.value.content.imageUrl || null,
linkUrl: formData.value.content.linkUrl || [""],
textLinkUrl: formData.value.content.textLinkUrl || [""],
keyword: Array.isArray(formData.value.content.keyword)
? formData.value.content.keyword.map((k) => (typeof k === "string" ? { keyword: k, value: "" } : k))
: [{ keyword: "", value: "" }],
},
previewMode: true,
}));
const currentFormComponent = computed(() => {
return markRaw(EmailForm1);
});
const currentTemplateComponent = computed(() => {
return markRaw(Template1);
});
// Methods
const handleChangeData = (data: Partial<FormData>) => {
formData.value.name = data.name || formData.value.name;
formData.value.id = data.id || formData.value.id;
if (data.content) {
formData.value.content = { ...formData.value.content, ...data.content };
}
};
const updateMappings = (newMappings: any[]) => {
formData.value.mappings = newMappings;
};
const resetToDefault = () => {
formData.value = {
name: "",
id: undefined,
template: defaultTemplate,
content: {
receiver: "",
subject: "",
content: "",
imageUrl: null,
linkUrl: [""],
textLinkUrl: [""],
keyword: [{ keyword: "", value: "" }],
html: "",
},
mappings: [],
};
dataTemplateSelected.value = null;
originalLoadedTemplate.value = null;
editMode.value = false;
formKey.value++;
};
const handleTemplateChange = async () => {
const templateValue = selectedValue.value;
if (templateValue === defaultTemplate) {
selectedTemplateId.value = null;
resetToDefault();
$snackbar("Template reset to default");
return;
}
const selectedTemplate = dataTemplate.value?.find((t) => t.name === templateValue);
if (selectedTemplate) {
originalLoadedTemplate.value = JSON.parse(JSON.stringify(selectedTemplate)); // Deep copy for diffing
selectedTemplateId.value = selectedTemplate.id;
dataTemplateSelected.value = selectedTemplate;
editMode.value = true;
const tplContent = selectedTemplate.content;
let emailBody = "";
let subject = "";
let receiver = "";
let imageUrl = null;
let linkUrl = [""];
let textLinkUrl = [""];
let keyword: any[] = [{ keyword: "", value: "" }];
let mappings: any[] = [];
if (typeof tplContent === "string") {
emailBody = tplContent;
subject = selectedTemplate.name;
} else if (typeof tplContent === "object" && tplContent !== null) {
emailBody = tplContent.content || ""; // Always use content
subject = tplContent.subject || "";
receiver = tplContent.receiver || "";
imageUrl = tplContent.imageUrl || null;
linkUrl = Array.isArray(tplContent.linkUrl) ? tplContent.linkUrl : [""];
textLinkUrl = Array.isArray(tplContent.textLinkUrl) ? tplContent.textLinkUrl : [""];
keyword = Array.isArray(tplContent.keyword) ? tplContent.keyword : [{ keyword: "", value: "" }];
mappings = tplContent.mappings || [];
}
formData.value = {
id: selectedTemplate.id,
name: selectedTemplate.name || "",
template: defaultTemplate,
content: {
receiver,
subject,
content: emailBody,
imageUrl,
linkUrl,
textLinkUrl,
keyword,
html: "", // Will be generated by preview
},
mappings,
};
formKey.value++;
$snackbar(`Template loaded: ${selectedTemplate.name}`);
}
};
const handleDeleteTemplate = async () => {
if (!selectedTemplateId.value || !dataTemplateSelected.value?.name) return;
try {
loading.value = true;
await $deleteapi('emailtemplate', selectedTemplateId.value);
$snackbar(`Template deleted: ${dataTemplateSelected.value.name}`);
await fetchTemplates();
selectedValue.value = defaultTemplate;
resetToDefault();
} catch (error) {
console.error("Error deleting template:", error);
$snackbar("Deleting template failed");
} finally {
loading.value = false;
showDeleteDialog.value = false;
}
};
const fetchTemplates = async () => {
try {
dataTemplate.value = await $getdata('emailtemplate');
} catch (error) {
$snackbar('Error: Failed to fetch templates');
console.error(error);
}
};
const handleOpenModal = () => {
let dataForModal;
if (editMode.value && originalLoadedTemplate.value) {
// EDIT MODE: Calculate a patch of changed data
const patchPayload: { id: number; name?: string; content?: Record<string, any> } = {
id: formData.value.id!,
};
// 1. Check if name has changed
if (formData.value.name !== originalLoadedTemplate.value.name) {
patchPayload.name = formData.value.name;
}
// 2. Reconstruct the content object, preserving all original fields
const originalContentObject = typeof originalLoadedTemplate.value.content === 'object'
? JSON.parse(JSON.stringify(originalLoadedTemplate.value.content))
: {};
const newContentObject = {
...originalContentObject,
subject: formData.value.content.subject,
content: formData.value.content.content,
receiver: formData.value.content.receiver,
imageUrl: formData.value.content.imageUrl,
linkUrl: formData.value.content.linkUrl,
textLinkUrl: formData.value.content.textLinkUrl,
keyword: formData.value.content.keyword,
mappings: formData.value.mappings,
};
// 3. Only include the 'content' field in the patch if it has actually changed
if (JSON.stringify(newContentObject) !== JSON.stringify(originalLoadedTemplate.value.content)) {
patchPayload.content = newContentObject;
}
dataForModal = patchPayload;
} else {
// CREATE MODE: Build the full object
const contentToSave = {
subject: formData.value.content.subject,
content: formData.value.content.content,
receiver: formData.value.content.receiver,
imageUrl: formData.value.content.imageUrl,
linkUrl: formData.value.content.linkUrl,
textLinkUrl: formData.value.content.textLinkUrl,
keyword: formData.value.content.keyword,
mappings: formData.value.mappings,
};
dataForModal = {
name: formData.value.name,
content: contentToSave,
};
}
showmodal.value = {
component: "marketing/email/dataTemplate/SaveListTemplate",
title: "Lưu mẫu email",
width: "auto",
height: "200px",
vbind: {
data: dataForModal,
editMode: editMode.value,
onSuccess: fetchTemplates,
},
};
};
// Watchers
watch(selectedValue, (newValue, oldValue) => {
if (newValue === defaultTemplate && oldValue !== defaultTemplate) {
resetToDefault();
}
});
// Lifecycle
onMounted(() => {
fetchTemplates();
});
</script>
<style scoped>
/* Custom Scrollbar */
.column::-webkit-scrollbar {
width: 8px;
}
.column::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 4px;
}
.column::-webkit-scrollbar-thumb {
background: #888;
border-radius: 4px;
}
.column::-webkit-scrollbar-thumb:hover {
background: #555;
}
/* Firefox */
.column {
scrollbar-width: thin;
scrollbar-color: #888 #f1f1f1;
}
</style>

View File

@@ -0,0 +1,256 @@
<template>
<div class="box">
<div class="level">
<div class="level-left">
<h5 class="title is-5">Automation Jobs</h5>
</div>
<div class="level-right">
<button class="button is-primary" @click="openNewJobForm">
<SvgIcon v-bind="{ name: 'add4.svg', type: 'white', size: 18 }" />
<span class="ml-2">Add New Job</span>
</button>
</div>
</div>
<div v-if="isLoading" class="has-text-centered">
<p>Loading jobs...</p>
</div>
<div v-else-if="jobs.length === 0" class="has-text-centered">
<p>No automation jobs found for this template.</p>
</div>
<div v-else>
<div v-for="job in jobs" :key="job.id" class="mb-4 p-3 border">
<div class="level">
<div class="level-left">
<div>
<p class="has-text-weight-bold">{{ job.name }}</p>
<p class="is-size-7">Model: {{ job.model_name }}</p>
<p class="is-size-7">
Triggers:
<span v-if="job.trigger_on_create" class="tag is-info is-light mr-1">On Create</span>
<span v-if="job.trigger_on_update" class="tag is-warning is-light">On Update</span>
</p>
</div>
</div>
<div class="level-right">
<div class="field has-addons">
<div class="control">
<button class="button is-small" :class="{'is-success': job.active, 'is-light': !job.active}" @click="toggleJobStatus(job)">
{{ job.active ? 'Active' : 'Inactive' }}
</button>
</div>
<div class="control">
<button class="button is-small" @click="openEditJobForm(job)">
<SvgIcon v-bind="{ name: 'pen1.svg', type: 'primary', size: 18 }" />
</button>
</div>
<div class="control">
<button class="button is-danger is-small" @click="confirmDelete(job)">
<SvgIcon v-bind="{ name: 'trash.svg', type: 'white', size: 18 }" />
</button>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Job Form Modal -->
<div v-if="showForm" class="modal is-active">
<div class="modal-background" @click="closeForm"></div>
<div class="modal-card">
<header class="modal-card-head">
<p class="modal-card-title">{{ isEditing ? 'Edit Job' : 'Create New Job' }}</p>
<button class="delete" @click="closeForm"></button>
</header>
<section class="modal-card-body">
<div class="field">
<label class="label">Job Name</label>
<div class="control">
<input class="input" type="text" v-model="jobForm.name" placeholder="e.g., Notify on Transaction Update">
</div>
</div>
<div class="field">
<label class="label">Model Name</label>
<div class="control">
<input class="input" type="text" v-model="jobForm.model_name" placeholder="e.g., app.Transaction_Detail">
</div>
</div>
<div class="field">
<label class="label">Triggers</label>
<div class="control">
<label class="checkbox mr-4">
<input type="checkbox" v-model="jobForm.trigger_on_create">
On Create
</label>
<label class="checkbox">
<input type="checkbox" v-model="jobForm.trigger_on_update">
On Update
</label>
</div>
</div>
<div class="field">
<label class="label">Status</label>
<div class="control">
<label class="checkbox">
<input type="checkbox" v-model="jobForm.active">
Active
</label>
</div>
</div>
</section>
<footer class="modal-card-foot">
<button class="button is-success" @click="saveJob" :disabled="isSaving">
{{ isSaving ? 'Saving...' : 'Save' }}
</button>
<button class="button" @click="closeForm">Cancel</button>
</footer>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch, onMounted } from 'vue';
import axios from 'axios';
import { useNuxtApp } from 'nuxt/app';
import { apiUrl, putApiUrl } from '@/components/marketing/email/Email.utils';
const props = defineProps({
templateId: {
type: [Number, null],
default: null,
},
});
const nuxtApp = useNuxtApp();
const $snackbar = nuxtApp.$snackbar as (message: string) => void;
const jobs = ref<any[]>([]);
const isLoading = ref(false);
const isSaving = ref(false);
const showForm = ref(false);
const isEditing = ref(false);
const defaultJobForm = () => ({
id: null,
name: '',
model_name: 'app.Transaction_Detail',
template: props.templateId,
trigger_on_create: false,
trigger_on_update: false,
active: true,
});
const jobForm = ref(defaultJobForm());
const fetchJobs = async () => {
if (!props.templateId) {
jobs.value = [];
return;
}
isLoading.value = true;
try {
const response = await axios.get(`${apiUrl}/Email_Job/`, {
params: { template_id: props.templateId },
});
jobs.value = response.data.rows || [];
} catch (error) {
console.error("Error fetching email jobs:", error);
$snackbar(`Error fetching jobs`);
} finally {
isLoading.value = false;
}
};
watch(() => props.templateId, fetchJobs, { immediate: true });
const openNewJobForm = () => {
if (!props.templateId) {
$snackbar(`Please save the template first.`);
return;
}
isEditing.value = false;
jobForm.value = defaultJobForm();
showForm.value = true;
};
const openEditJobForm = (job: any) => {
isEditing.value = true;
jobForm.value = { ...job };
showForm.value = true;
};
const closeForm = () => {
showForm.value = false;
};
const saveJob = async () => {
isSaving.value = true;
try {
let response;
const data = { ...jobForm.value };
if (isEditing.value) {
response = await axios.put(`${putApiUrl}/Email_Job/${data.id}`, data);
} else {
response = await axios.post(`${apiUrl}/Email_Job/`, data);
}
if (response.status === 200 || response.status === 201) {
$snackbar(`Job saved successfully`);
await fetchJobs();
closeForm();
}
} catch (error) {
console.error("Error saving job:", error);
$snackbar(`Error saving job`);
} finally {
isSaving.value = false;
}
};
const toggleJobStatus = async (job: any) => {
const updatedJob = { ...job, active: !job.active };
try {
await axios.put(`${putApiUrl}/Email_Job/${job.id}`, updatedJob);
$snackbar(`Job status updated`);
await fetchJobs();
} catch (error) {
console.error("Error updating job status:", error);
$snackbar(`Error updating job status`);
}
};
const confirmDelete = (job: any) => {
if (confirm(`Are you sure you want to delete the job "${job.name}"?`)) {
deleteJob(job.id);
}
};
const deleteJob = async (jobId: number) => {
try {
await axios.delete(`${putApiUrl}/Email_Job/${jobId}`);
$snackbar(`Job deleted successfully`);
await fetchJobs();
} catch (error) {
console.error("Error deleting job:", error);
$snackbar(`Error deleting job`);
}
};
onMounted(fetchJobs);
</script>
<style scoped>
.box {
padding: 1.5rem;
}
.border {
border: 1px solid #dbdbdb;
border-radius: 4px;
}
.modal-card-foot {
justify-content: flex-end;
}
</style>

View File

@@ -0,0 +1,192 @@
<template>
<div class="box">
<div v-for="(mapping, mapIndex) in localMappings" :key="mapIndex" class="mb-5 p-4 border">
<div class="level">
<div class="level-left">
<h5 class="title is-5">Mapping {{ mapIndex + 1 }}: {{ mapping.alias }}</h5>
</div>
<div class="level-right">
<button class="button is-danger is-small" @click="removeMapping(mapIndex)">
<span class="icon is-small">
<SvgIcon v-bind="{ name: 'bin1.svg', type: 'white', size: 16 }"></SvgIcon>
</span>
</button>
</div>
</div>
<div class="columns is-multiline">
<div class="column is-6">
<div class="field">
<label class="label">Alias</label>
<div class="control">
<input class="input" type="text" v-model="mapping.alias" @input="update" />
</div>
</div>
</div>
<div class="column is-6">
<div class="field">
<label class="label">Model</label>
<div class="control">
<input class="input" type="text" v-model="mapping.model" @input="update" placeholder="e.g., app.Transaction" />
</div>
</div>
</div>
<div class="column is-6">
<div class="field">
<label class="label">Lookup Field</label>
<div class="control">
<input class="input" type="text" v-model="mapping.lookup_field" @input="update" placeholder="e.g., id" />
</div>
</div>
</div>
<div class="column is-6">
<div class="field">
<label class="label">Lookup Value From</label>
<div class="control">
<input class="input" type="text" v-model="mapping.lookup_value_from" @input="update" placeholder="e.g., transaction_id or transaction.customer.id" />
</div>
</div>
</div>
<div class="column is-6">
<div class="field">
<label class="label">Type</label>
<div class="control">
<div class="select is-fullwidth">
<select v-model="mapping.type" @change="update">
<option value="object">Object</option>
<option value="list">List</option>
</select>
</div>
</div>
</div>
</div>
<div class="column is-6">
<div class="field">
<label class="label">Is Trigger Object?</label>
<div class="control">
<label class="checkbox">
<input type="checkbox" v-model="mapping.is_trigger_object" @change="update">
Yes
</label>
</div>
</div>
</div>
</div>
<hr>
<h6 class="title is-6">Fields</h6>
<div v-for="(field, fieldIndex) in mapping.fields" :key="fieldIndex" class="field has-addons">
<div class="control">
<input class="input" type="text" v-model="field.placeholder" @input="update" placeholder="[placeholder]">
</div>
<div class="control is-expanded">
<input class="input" type="text" v-model="field.source" @input="update" placeholder="source.field.name">
</div>
<div class="control">
<button class="button is-danger is-light" @click="removeField(mapIndex, fieldIndex as number)">
<SvgIcon v-bind="{ name: 'bin1.svg', type: 'primary'}"></SvgIcon>
</button>
</div>
</div>
<button class="button is-small is-light" @click="addField(mapIndex)">
<span class="icon is-small">+</span>
<span>Add Field</span>
</button>
</div>
<button class="button is-primary" @click="addMapping">
<SvgIcon class="mr-2" v-bind="{ name: 'add4.svg', type: 'white', size: 18 }" />
Add Mapping
</button>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue';
const props = defineProps({
mappings: {
type: Array,
default: () => [],
},
});
const emit = defineEmits(['update:mappings']);
// Use a local ref to manage mappings to handle the object-to-array transformation for fields
const localMappings = ref<any[]>([]);
// Helper to transform fields object to array for v-for
const transformMappings = (sourceMappings: any[]) => {
return sourceMappings.map(m => ({
...m,
fields: m.fields ? Object.entries(m.fields).map(([placeholder, source]) => ({ placeholder, source: typeof source === 'object' ? JSON.stringify(source) : source })) : []
}));
};
// Helper to transform fields array back to object for emission
const reformatMappingsForEmit = () => {
return localMappings.value.map(m => {
const newFields = m.fields.reduce((obj: any, item: any) => {
try {
// Attempt to parse if it's a JSON string for format objects
obj[item.placeholder] = JSON.parse(item.source);
} catch (e) {
obj[item.placeholder] = item.source;
}
return obj;
}, {});
return { ...m, fields: newFields };
});
};
watch(() => props.mappings, (newMappings) => {
localMappings.value = transformMappings(newMappings || []);
}, { immediate: true, deep: true });
const addMapping = () => {
localMappings.value.push({
alias: '',
model: '',
lookup_field: 'id',
lookup_value_from: '',
type: 'object',
is_trigger_object: false,
fields: [],
});
update();
};
const removeMapping = (index: number) => {
localMappings.value.splice(index, 1);
update();
};
const addField = (mapIndex: number) => {
if (!localMappings.value[mapIndex].fields) {
localMappings.value[mapIndex].fields = [];
}
localMappings.value[mapIndex].fields.push({ placeholder: '', source: '' });
update();
};
const removeField = (mapIndex: number, fieldIndex: number) => {
localMappings.value[mapIndex].fields.splice(fieldIndex, 1);
update();
};
const update = () => {
emit('update:mappings', reformatMappingsForEmit());
};
</script>
<style scoped>
.box {
padding: 1.5rem;
}
.border {
border: 1px solid #dbdbdb;
border-radius: 4px;
}
</style>

View File

@@ -0,0 +1,347 @@
<template>
<div class="container">
<div class="level mb-4">
<div class="level-left">
<div class="level-item">
<div>
<h2 class="title is-4">Sent Emails</h2>
<p class="subtitle is-6">
{{ loading ? "Loading..." : `${emailsSent.length} email(s) sent` }}
</p>
</div>
</div>
</div>
<div class="level-right">
<div class="level-item">
<button
class="button is-light"
@click="getDataEmailsSent"
:disabled="loading"
:class="{ 'is-loading': loading }"
>
<span class="icon">
<RefreshCw :size="16" />
</span>
<span>Refresh</span>
</button>
</div>
</div>
</div>
<div class="box">
<table class="table is-fullwidth is-striped is-hoverable">
<thead>
<tr>
<th>Emails</th>
<th>Subject</th>
<th>Content</th>
<th>Status</th>
<th>Sent At</th>
<th>Updated At</th>
</tr>
</thead>
<tbody>
<template v-if="loading">
<tr v-for="index in 5" :key="index">
<td><div class="skeleton-line"></div></td>
<td><div class="skeleton-line"></div></td>
<td><div class="skeleton-line"></div></td>
<td><div class="skeleton-line"></div></td>
<td><div class="skeleton-line"></div></td>
<td><div class="skeleton-line"></div></td>
</tr>
</template>
<template v-else-if="emailsSent.length === 0">
<tr>
<td colspan="6">
<div class="has-text-centered py-6">
<Mail :size="64" class="has-text-grey-light mb-4" />
<p class="title is-5">No emails sent yet</p>
<p class="subtitle is-6">Sent emails will appear here</p>
</div>
</td>
</tr>
</template>
<template v-else>
<tr v-for="email in emailsSent" :key="email.id" class="is-clickable" @click="handleViewDetail(email)">
<td>
<span>{{ truncateText(email.receiver, 40) }}</span>
</td>
<td>{{ truncateText(email.subject, 20) }}</td>
<td>
<div v-html="truncateText(stripHtml(extractMessageContent(email.content), 1000), 40)"></div>
</td>
<td>
<span v-if="email.status === 1" class="tag is-warning">
<span class="icon is-small">
<SvgIcon v-bind="{ name: 'loading.svg', type: 'white', size: 12 }" />
</span>
<span>Pending</span>
</span>
<span v-else-if="email.status === 2" class="tag is-success">
<span class="icon is-small">
<CheckCircle2 :size="12" />
</span>
<span>Sent</span>
</span>
<span v-else-if="email.status === 3" class="tag is-danger">
<span class="icon is-small">
<XCircle :size="12" />
</span>
<span>Failed</span>
</span>
<span v-else-if="email.status === 4" class="tag is-info">
<span class="icon is-small">
<Clock :size="12" />
</span>
<span>Scheduled</span>
</span>
</td>
<td>
<div>
<p class="is-size-7">
<span class="icon is-small">
<Calendar :size="12" />
</span>
{{ formatDate(email.create_time) }}
</p>
<p class="is-size-7 has-text-grey">{{ getRelativeTime(email.create_time) }}</p>
</div>
</td>
<td>
<div>
<p class="is-size-7">
<span class="icon is-small">
<Clock :size="12" />
</span>
{{ formatDate(email.update_time || "") }}
</p>
<p class="is-size-7 has-text-grey">{{ getRelativeTime(email.update_time || "") || "---" }}</p>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<!-- Detail Modal -->
<div v-if="showDetailModal && selectedEmail" class="modal is-active">
<div class="modal-background" @click="showDetailModal = false"></div>
<div class="modal-card" style="width: 90%; max-width: 1200px">
<header class="modal-card-head">
<p class="modal-card-title">Email Details</p>
<button class="delete" @click="showDetailModal = false"></button>
</header>
<section class="modal-card-body">
<div class="field">
<label class="label">
Recipients ({{ selectedEmail.receiver.split(";").filter((e) => e.trim()).length }})
</label>
<div class="box" style="max-height: 150px; overflow-y: auto">
<p style="white-space: pre-wrap">{{ selectedEmail.receiver }}</p>
</div>
</div>
<div class="field">
<label class="label">Subject</label>
<p>{{ selectedEmail.subject }}</p>
</div>
<div class="field">
<label class="label">Content</label>
<div
class="box content"
style="max-height: 300px; overflow-y: auto"
v-html="selectedEmail.content || 'No content'"
></div>
</div>
<div class="columns">
<div class="column">
<label class="label">Status</label>
<div>
<span v-if="selectedEmail.status === 1" class="tag is-warning">Pending</span>
<span v-else-if="selectedEmail.status === 2" class="tag is-success">Sent</span>
<span v-else-if="selectedEmail.status === 3" class="tag is-danger">Failed</span>
<span v-else-if="selectedEmail.status === 4" class="tag is-info">Scheduled</span>
</div>
</div>
<div class="column">
<label class="label">Sent At</label>
<p>{{ new Date(selectedEmail.create_time).toLocaleString("vi-VN") }}</p>
</div>
<div class="column">
<label class="label">Updated At</label>
<p>
{{ selectedEmail.update_time ? new Date(selectedEmail.update_time).toLocaleString("vi-VN") : "---" }}
</p>
</div>
</div>
</section>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { formatDistanceToNow } from "date-fns";
import axios from "axios";
import { useNuxtApp } from "nuxt/app";
import { apiUrl } from '@/components/marketing/email/Email.utils';
interface EmailSent {
id: number;
receiver: string;
subject: string;
content: string;
status: number;
create_time: string;
update_time?: string;
}
const nuxtApp = useNuxtApp();
const $snackbar = nuxtApp.$snackbar as (message?: string) => void;
const emailsSent = ref<EmailSent[]>([]);
const loading = ref(true);
const selectedEmail = ref<EmailSent | null>(null);
const showDetailModal = ref(false);
const extractMessageContent = (html: string | null | undefined): string => {
if (!html || html === "") return "No content";
try {
if (typeof window !== "undefined") {
const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");
const messageContentDiv = doc.querySelector("div.message-content");
if (messageContentDiv) {
const content = messageContentDiv.innerHTML.trim();
if (content) {
return content;
}
return messageContentDiv.innerHTML;
}
} else {
const regex = /<div[^>]*class=["'][^"']*message-content[^"']*["'][^>]*>([\s\S]*?)<\/div>/i;
const match = html.match(regex);
if (match && match[1]) {
return match[1].trim();
}
}
} catch (error) {
console.error("Error extracting message content:", error);
}
return html;
};
const stripHtml = (html: string, maxLength: number = 1000): string => {
if (!html) return "";
const text = html.replace(/<[^>]*>/g, "");
return text.length > maxLength ? text.substring(0, maxLength) + "..." : text;
};
const getDataEmailsSent = async () => {
loading.value = true;
try {
const response = await axios.get(`${apiUrl}/Email_Sent/?sort=-id`);
if (response.status === 200) {
emailsSent.value = response.data.rows || response.data || [];
}
} catch (error) {
console.error("Error fetching sent emails:", error);
$snackbar("Failed to load sent emails");
} finally {
loading.value = false;
}
};
const formatDate = (dateString: string) => {
try {
if (dateString === "") return "";
const date = new Date(dateString);
return date.toLocaleString("vi-VN", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
} catch {
return "Invalid date";
}
};
const getRelativeTime = (dateString: string) => {
try {
if (dateString === "") return "";
return formatDistanceToNow(new Date(dateString), { addSuffix: true });
} catch {
return "";
}
};
const handleViewDetail = (email: EmailSent) => {
selectedEmail.value = email;
showDetailModal.value = true;
};
const truncateText = (text: string | undefined | null, maxLength: number = 50): string => {
if (!text) return "";
const textStr = String(text);
if (textStr.length <= maxLength) return textStr;
return textStr.substring(0, maxLength) + "...";
};
onMounted(() => {
getDataEmailsSent();
});
</script>
<style scoped>
.is-clickable {
cursor: pointer;
}
.is-clickable:hover {
background-color: #f5f5f5;
}
.skeleton-line {
height: 20px;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: loading 1.5s infinite;
border-radius: 4px;
}
@keyframes loading {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
.rotating {
animation: rotate 1s linear infinite;
}
@keyframes rotate {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
</style>

Some files were not shown because too many files have changed in this diff Show More