Initial commit
This commit is contained in:
5
.dockerignore
Normal file
5
.dockerignore
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
.output
|
||||
.nuxt
|
||||
dist
|
||||
.git
|
||||
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal 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
35
Dockerfile
Normal 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
75
README.md
Normal 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
5
app.vue
Normal file
@@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<NuxtLayout>
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
</template>
|
||||
442
app/assets/styles/main.scss
Normal file
442
app/assets/styles/main.scss
Normal 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;
|
||||
}
|
||||
14
app/components/Caption.vue
Normal file
14
app/components/Caption.vue
Normal 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
133
app/components/Modal.vue
Normal 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>
|
||||
275
app/components/SearchBox.vue
Normal file
275
app/components/SearchBox.vue
Normal 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 có 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>
|
||||
72
app/components/SvgIcon.vue
Normal file
72
app/components/SvgIcon.vue
Normal 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>
|
||||
14
app/components/Tooltip.vue
Normal file
14
app/components/Tooltip.vue
Normal 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
171
app/components/TopMenu.vue
Normal 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>
|
||||
79
app/components/accounting/AccountView.vue
Normal file
79
app/components/accounting/AccountView.vue
Normal 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>
|
||||
143
app/components/accounting/ConfirmDeleteEntry.vue
Normal file
143
app/components/accounting/ConfirmDeleteEntry.vue
Normal 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 có 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 có) 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>
|
||||
73
app/components/accounting/DebtCustomer.vue
Normal file
73
app/components/accounting/DebtCustomer.vue
Normal 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>
|
||||
74
app/components/accounting/DebtProduct.vue
Normal file
74
app/components/accounting/DebtProduct.vue
Normal 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>
|
||||
581
app/components/accounting/DebtView.vue
Normal file
581
app/components/accounting/DebtView.vue
Normal 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 có 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>
|
||||
31
app/components/accounting/InternalAccount.vue
Normal file
31
app/components/accounting/InternalAccount.vue
Normal 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>
|
||||
228
app/components/accounting/InternalDeposit.vue
Normal file
228
app/components/accounting/InternalDeposit.vue
Normal 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ố dư 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>
|
||||
247
app/components/accounting/InternalEntry.vue
Normal file
247
app/components/accounting/InternalEntry.vue
Normal 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">Dư trước:</label>
|
||||
<div class="control">
|
||||
{{ $numtoString(record.balance_before) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-3">
|
||||
<div class="field">
|
||||
<label class="label">Dư sau:</label>
|
||||
<div class="control">
|
||||
{{ $numtoString(record.balance_after) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column is-3">
|
||||
<div class="field">
|
||||
<label class="label">Mã 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">Mã 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">Mã 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 có 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>
|
||||
104
app/components/accounting/InternalTransfer.vue
Normal file
104
app/components/accounting/InternalTransfer.vue
Normal 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ố dư để đ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>
|
||||
290
app/components/accounting/TransactionInvoice.vue
Normal file
290
app/components/accounting/TransactionInvoice.vue
Normal 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">Mã 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">Mã 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"> </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>
|
||||
21
app/components/application/ApplicationImage.vue
Normal file
21
app/components/application/ApplicationImage.vue
Normal 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>
|
||||
964
app/components/application/CalculationView.vue
Normal file
964
app/components/application/CalculationView.vue
Normal 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>
|
||||
148
app/components/application/CommPayment.vue
Normal file
148
app/components/application/CommPayment.vue
Normal 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>
|
||||
262
app/components/application/Contract.vue
Normal file
262
app/components/application/Contract.vue
Normal 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 có 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>
|
||||
423
app/components/application/ContractPaymentUpload.vue
Normal file
423
app/components/application/ContractPaymentUpload.vue
Normal 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>
|
||||
4
app/components/application/NoPermission.vue
Normal file
4
app/components/application/NoPermission.vue
Normal 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 có quyền xem thông tin sản phẩm này.</p>
|
||||
</template>
|
||||
881
app/components/application/PaymentSchedule.vue
Normal file
881
app/components/application/PaymentSchedule.vue
Normal 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 có 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Ự CÓ
|
||||
</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: </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 có 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>
|
||||
504
app/components/application/PaymentSchedulePresentation.vue
Normal file
504
app/components/application/PaymentSchedulePresentation.vue
Normal 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: </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>
|
||||
1240
app/components/application/PhaseAdvance.vue
Normal file
1240
app/components/application/PhaseAdvance.vue
Normal file
File diff suppressed because it is too large
Load Diff
251
app/components/common/ActionInfo.vue
Normal file
251
app/components/common/ActionInfo.vue
Normal 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>
|
||||
28
app/components/common/Avatarbox.vue
Normal file
28
app/components/common/Avatarbox.vue
Normal 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>
|
||||
41
app/components/common/CountWithAdd.vue
Normal file
41
app/components/common/CountWithAdd.vue
Normal 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>
|
||||
135
app/components/common/CountdownTimer.vue
Normal file
135
app/components/common/CountdownTimer.vue
Normal 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>
|
||||
64
app/components/common/Editor.vue
Normal file
64
app/components/common/Editor.vue
Normal 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>
|
||||
37
app/components/common/InputEmail.vue
Normal file
37
app/components/common/InputEmail.vue
Normal 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>
|
||||
94
app/components/common/InputNumber.vue
Normal file
94
app/components/common/InputNumber.vue
Normal 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>
|
||||
55
app/components/common/InputPhone.vue
Normal file
55
app/components/common/InputPhone.vue
Normal 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>
|
||||
27
app/components/common/Note.vue
Normal file
27
app/components/common/Note.vue
Normal 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>
|
||||
125
app/components/common/NoteInfo.vue
Normal file
125
app/components/common/NoteInfo.vue
Normal 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 có <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>
|
||||
18
app/components/common/Notebox.vue
Normal file
18
app/components/common/Notebox.vue
Normal 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>
|
||||
54
app/components/common/Phone.vue
Normal file
54
app/components/common/Phone.vue
Normal 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>
|
||||
25
app/components/common/ProductCountbox.vue
Normal file
25
app/components/common/ProductCountbox.vue
Normal 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>
|
||||
21
app/components/common/ProductInfo.vue
Normal file
21
app/components/common/ProductInfo.vue
Normal 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>
|
||||
127
app/components/common/QRcode.vue
Normal file
127
app/components/common/QRcode.vue
Normal 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 có 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>
|
||||
10
app/components/common/ViewList.vue
Normal file
10
app/components/common/ViewList.vue
Normal file
@@ -0,0 +1,10 @@
|
||||
<template>
|
||||
<div>
|
||||
<DataView v-if="vbind" v-bind="vbind"></DataView>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
export default {
|
||||
props: ['vbind']
|
||||
}
|
||||
</script>
|
||||
175
app/components/customer/Company.vue
Normal file
175
app/components/customer/Company.vue
Normal 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>
|
||||
136
app/components/customer/Customer.vue
Normal file
136
app/components/customer/Customer.vue
Normal file
@@ -0,0 +1,136 @@
|
||||
<template>
|
||||
<!-- Nội dung chính - chỉ hiển thị form cá nhân (màn hình chọn đã được xử lý ở 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>
|
||||
783
app/components/customer/CustomerInfo.vue
Normal file
783
app/components/customer/CustomerInfo.vue
Normal 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ử lý people relationships nếu có
|
||||
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>
|
||||
476
app/components/customer/CustomerInfo2.vue
Normal file
476
app/components/customer/CustomerInfo2.vue
Normal 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"> </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>
|
||||
69
app/components/customer/CustomerTypeSelector.vue
Normal file
69
app/components/customer/CustomerTypeSelector.vue
Normal 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>
|
||||
301
app/components/customer/CustomerView.vue
Normal file
301
app/components/customer/CustomerView.vue
Normal 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">Mã 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">Mã 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 có {{ 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>
|
||||
540
app/components/datatable/ContextMenu.vue
Normal file
540
app/components/datatable/ContextMenu.vue
Normal 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>
|
||||
431
app/components/datatable/CreateTemplate.vue
Normal file
431
app/components/datatable/CreateTemplate.vue
Normal 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 có 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 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ã 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ã 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>
|
||||
193
app/components/datatable/DataModel.vue
Normal file
193
app/components/datatable/DataModel.vue
Normal 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 có 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>
|
||||
550
app/components/datatable/DataTable.vue
Normal file
550
app/components/datatable/DataTable.vue
Normal 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) : '') + '...Σ' + 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>
|
||||
413
app/components/datatable/DataView.vue
Normal file
413
app/components/datatable/DataView.vue
Normal 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>
|
||||
82
app/components/datatable/EditLabel.vue
Normal file
82
app/components/datatable/EditLabel.vue
Normal 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>
|
||||
88
app/components/datatable/FieldAttribute.vue
Normal file
88
app/components/datatable/FieldAttribute.vue
Normal 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>
|
||||
170
app/components/datatable/FilterOption.vue
Normal file
170
app/components/datatable/FilterOption.vue
Normal 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 có 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 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 kí 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ã 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>
|
||||
207
app/components/datatable/FormatOption.vue
Normal file
207
app/components/datatable/FormatOption.vue
Normal 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, mã 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>
|
||||
161
app/components/datatable/MenuSave.vue
Normal file
161
app/components/datatable/MenuSave.vue
Normal 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>
|
||||
159
app/components/datatable/ModelInfo.vue
Normal file
159
app/components/datatable/ModelInfo.vue
Normal 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
|
||||
334
app/components/datatable/NewField.vue
Normal file
334
app/components/datatable/NewField.vue
Normal 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 có 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">Mô 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>
|
||||
64
app/components/datatable/Pagination.vue
Normal file
64
app/components/datatable/Pagination.vue
Normal 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>
|
||||
1036
app/components/datatable/PivotDataView.vue
Normal file
1036
app/components/datatable/PivotDataView.vue
Normal file
File diff suppressed because it is too large
Load Diff
117
app/components/datatable/ScrollBox.vue
Normal file
117
app/components/datatable/ScrollBox.vue
Normal 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>
|
||||
99
app/components/datatable/TableOption.vue
Normal file
99
app/components/datatable/TableOption.vue
Normal 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>
|
||||
112
app/components/datatable/TableSetting.vue
Normal file
112
app/components/datatable/TableSetting.vue
Normal 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>
|
||||
365
app/components/datatable/TimeOption.vue
Normal file
365
app/components/datatable/TimeOption.vue
Normal 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">Có 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>
|
||||
10
app/components/datatable/format/ColorText.vue
Normal file
10
app/components/datatable/format/ColorText.vue
Normal 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>
|
||||
10
app/components/datatable/format/FormatDate.vue
Normal file
10
app/components/datatable/format/FormatDate.vue
Normal 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>
|
||||
10
app/components/datatable/format/FormatNumber.vue
Normal file
10
app/components/datatable/format/FormatNumber.vue
Normal 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>
|
||||
10
app/components/datatable/format/FormatTime.vue
Normal file
10
app/components/datatable/format/FormatTime.vue
Normal 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>
|
||||
130
app/components/datepicker/Datepicker.vue
Normal file
130
app/components/datepicker/Datepicker.vue
Normal 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>
|
||||
185
app/components/datepicker/EventDetail.vue
Normal file
185
app/components/datepicker/EventDetail.vue
Normal 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>
|
||||
19
app/components/datepicker/EventMultiMonth.vue
Normal file
19
app/components/datepicker/EventMultiMonth.vue
Normal 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>
|
||||
52
app/components/datepicker/EventOneMonth.vue
Normal file
52
app/components/datepicker/EventOneMonth.vue
Normal 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>
|
||||
189
app/components/datepicker/EventSummary.vue
Normal file
189
app/components/datepicker/EventSummary.vue
Normal 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>
|
||||
181
app/components/datepicker/PickDay.vue
Normal file
181
app/components/datepicker/PickDay.vue
Normal 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>
|
||||
14
app/components/datepicker/PickMonth.vue
Normal file
14
app/components/datepicker/PickMonth.vue
Normal 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>
|
||||
57
app/components/datepicker/PickYear.vue
Normal file
57
app/components/datepicker/PickYear.vue
Normal 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>
|
||||
16
app/components/datepicker/ViewEvent.vue
Normal file
16
app/components/datepicker/ViewEvent.vue
Normal 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
363
app/components/debt/Due.vue
Normal 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>
|
||||
363
app/components/debt/Overdue.vue
Normal file
363
app/components/debt/Overdue.vue
Normal 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>
|
||||
69
app/components/dialog/ApprovalCode.vue
Normal file
69
app/components/dialog/ApprovalCode.vue
Normal 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('Mã phê duyệt không hợp lệ')
|
||||
if(this.code!==this.approvalcode) return this.$snackbar('Mã 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>
|
||||
29
app/components/dialog/Confirm.vue
Normal file
29
app/components/dialog/Confirm.vue
Normal 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>
|
||||
75
app/components/dialog/CountDown.vue
Normal file
75
app/components/dialog/CountDown.vue
Normal 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>
|
||||
47
app/components/dialog/Delete.vue
Normal file
47
app/components/dialog/Delete.vue
Normal 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>
|
||||
31
app/components/dialog/Error.vue
Normal file
31
app/components/dialog/Error.vue
Normal 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>
|
||||
24
app/components/dialog/Info.vue
Normal file
24
app/components/dialog/Info.vue
Normal 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>
|
||||
110
app/components/dialog/NoteInput.vue
Normal file
110
app/components/dialog/NoteInput.vue
Normal 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>
|
||||
29
app/components/dialog/Success.vue
Normal file
29
app/components/dialog/Success.vue
Normal 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>
|
||||
178
app/components/maintab/Configuration.vue
Normal file
178
app/components/maintab/Configuration.vue
Normal 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>
|
||||
5
app/components/marketing/email/Email.utils.js
Normal file
5
app/components/marketing/email/Email.utils.js
Normal 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";
|
||||
471
app/components/marketing/email/Email.vue
Normal file
471
app/components/marketing/email/Email.vue
Normal 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 có 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>
|
||||
256
app/components/marketing/email/JobConfigurator.vue
Normal file
256
app/components/marketing/email/JobConfigurator.vue
Normal 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>
|
||||
192
app/components/marketing/email/MappingConfigurator.vue
Normal file
192
app/components/marketing/email/MappingConfigurator.vue
Normal 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>
|
||||
347
app/components/marketing/email/dataGmail/EmailSent.vue
Normal file
347
app/components/marketing/email/dataGmail/EmailSent.vue
Normal 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
Reference in New Issue
Block a user