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
|
||||||
51
.gitea/workflows/deploy-dev.yaml
Normal file
51
.gitea/workflows/deploy-dev.yaml
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
name: Deploy Utopia Dev
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- dev
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ gitea.workflow }}-${{ gitea.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Deploy to server via SSH
|
||||||
|
uses: appleboy/ssh-action@v1.2.5
|
||||||
|
with:
|
||||||
|
host: ${{ secrets.SSH_HOST_DEV }}
|
||||||
|
username: ${{ secrets.SSH_USER_DEV }}
|
||||||
|
password: ${{ secrets.SSH_PASSWORD_DEV }}
|
||||||
|
port: ${{ secrets.SSH_PORT_DEV }}
|
||||||
|
script: |
|
||||||
|
cd ~/deploy/utopiadev
|
||||||
|
echo "Pulling latest code..."
|
||||||
|
git pull
|
||||||
|
echo "Starting deployment..."
|
||||||
|
sh start.sh
|
||||||
|
echo "Deploy done"
|
||||||
|
|
||||||
|
cleanup:
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
needs: deploy
|
||||||
|
if: always()
|
||||||
|
steps:
|
||||||
|
- name: Cleanup Docker on server
|
||||||
|
uses: appleboy/ssh-action@v1.2.5
|
||||||
|
with:
|
||||||
|
host: ${{ secrets.SSH_HOST_DEV }}
|
||||||
|
username: ${{ secrets.SSH_USER_DEV }}
|
||||||
|
password: ${{ secrets.SSH_PASSWORD_DEV }}
|
||||||
|
port: ${{ secrets.SSH_PORT_DEV }}
|
||||||
|
script: |
|
||||||
|
echo "=== Starting Docker cleanup ==="
|
||||||
|
docker images --format "{{.Repository}}:{{.Tag}} {{.ID}}" | \
|
||||||
|
grep -v ":latest" | \
|
||||||
|
awk '{print $2}' | \
|
||||||
|
xargs -r docker rmi || true
|
||||||
|
docker system prune -f
|
||||||
|
echo "=== Cleanup completed ==="
|
||||||
51
.gitea/workflows/deploy-pro.yaml
Normal file
51
.gitea/workflows/deploy-pro.yaml
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
name: Deploy Utopia Product
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- product
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ${{ gitea.workflow }}-${{ gitea.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Deploy to server via SSH
|
||||||
|
uses: appleboy/ssh-action@v1.2.5
|
||||||
|
with:
|
||||||
|
host: ${{ secrets.SSH_HOST_PRO }}
|
||||||
|
username: ${{ secrets.SSH_USER_PRO }}
|
||||||
|
password: ${{ secrets.SSH_PASSWORD_PRO }}
|
||||||
|
port: ${{ secrets.SSH_PORT_PRO }}
|
||||||
|
script: |
|
||||||
|
cd ~/deploy/utopia
|
||||||
|
echo "Pulling latest code..."
|
||||||
|
git pull
|
||||||
|
echo "Starting deployment..."
|
||||||
|
sh start.sh
|
||||||
|
echo "Deploy done"
|
||||||
|
|
||||||
|
cleanup:
|
||||||
|
runs-on: ubuntu-24.04
|
||||||
|
needs: deploy
|
||||||
|
if: always()
|
||||||
|
steps:
|
||||||
|
- name: Cleanup Docker on server
|
||||||
|
uses: appleboy/ssh-action@v1.2.5
|
||||||
|
with:
|
||||||
|
host: ${{ secrets.SSH_HOST_PRO }}
|
||||||
|
username: ${{ secrets.SSH_USER_PRO }}
|
||||||
|
password: ${{ secrets.SSH_PASSWORD_PRO }}
|
||||||
|
port: ${{ secrets.SSH_PORT_PRO }}
|
||||||
|
script: |
|
||||||
|
echo "=== Starting Docker cleanup ==="
|
||||||
|
docker images --format "{{.Repository}}:{{.Tag}} {{.ID}}" | \
|
||||||
|
grep -v ":latest" | \
|
||||||
|
awk '{print $2}' | \
|
||||||
|
xargs -r docker rmi || true
|
||||||
|
docker system prune -f
|
||||||
|
echo "=== Cleanup completed ==="
|
||||||
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
|
||||||
36
Dockerfile
Normal file
36
Dockerfile
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# --- 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 . .
|
||||||
|
ENV NODE_OPTIONS=--max-old-space-size=8192
|
||||||
|
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>
|
||||||
528
app/assets/styles/main.scss
Normal file
528
app/assets/styles/main.scss
Normal file
@@ -0,0 +1,528 @@
|
|||||||
|
@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
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
.w-full {
|
||||||
|
width: 100%
|
||||||
|
}
|
||||||
|
|
||||||
|
.h-full {
|
||||||
|
height: 100%
|
||||||
|
}
|
||||||
|
|
||||||
|
.fullheight {
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.font-thin {
|
||||||
|
font-weight: 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
.font-extralight {
|
||||||
|
font-weight: 200;
|
||||||
|
}
|
||||||
|
|
||||||
|
.font-light {
|
||||||
|
font-weight: 300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.font-normal {
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
||||||
|
|
||||||
|
.font-medium {
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.font-semibold {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.font-bold {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.font-extrabold {
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.font-black {
|
||||||
|
font-weight: 900;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rounded-xs {
|
||||||
|
border-radius: 0.125rem
|
||||||
|
}
|
||||||
|
|
||||||
|
.rounded-sm {
|
||||||
|
border-radius: 0.25rem
|
||||||
|
}
|
||||||
|
|
||||||
|
.rounded-md {
|
||||||
|
border-radius: 0.375rem
|
||||||
|
}
|
||||||
|
|
||||||
|
.rounded-lg {
|
||||||
|
border-radius: 0.5rem
|
||||||
|
}
|
||||||
|
|
||||||
|
.rounded-xl {
|
||||||
|
border-radius: 0.75rem
|
||||||
|
}
|
||||||
|
|
||||||
|
.rounded-2xl {
|
||||||
|
border-radius: 1rem
|
||||||
|
}
|
||||||
|
|
||||||
|
.rounded-3xl {
|
||||||
|
border-radius: 1.5rem
|
||||||
|
}
|
||||||
|
|
||||||
|
.rounded-4xl {
|
||||||
|
border-radius: 2rem
|
||||||
|
}
|
||||||
|
|
||||||
|
.rounded-none {
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rounded-full {
|
||||||
|
border-radius: calc(infinity * 1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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>
|
||||||
129
app/components/Modal.vue
Normal file
129
app/components/Modal.vue
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
<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};`">
|
||||||
|
<header
|
||||||
|
class="modal-card-head my-0 p-2 pl-4 is-justify-content-space-between is-shadowless border-bottom"
|
||||||
|
v-if="title"
|
||||||
|
>
|
||||||
|
<p class="fsb-17 has-text-primary" v-html="title"></p>
|
||||||
|
<button
|
||||||
|
class="button rounded-full p-2 is-white"
|
||||||
|
@click="closeModal()"
|
||||||
|
>
|
||||||
|
<SvgIcon v-bind="{ name: 'close.svg', size: 16 }" />
|
||||||
|
</button>
|
||||||
|
</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 w-full ${ pos || ''} ${focused? 'is-active' : ''}`">
|
||||||
|
<div class="dropdown-trigger w-full">
|
||||||
|
<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 h-full" @click="clearValue" 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 h-full" @click="viewInfo()" 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 h-full" @click="addNew()" 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>
|
||||||
73
app/components/SvgIcon.vue
Normal file
73
app/components/SvgIcon.vue
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
<template>
|
||||||
|
<NuxtImg
|
||||||
|
loading="lazy"
|
||||||
|
role="presentation"
|
||||||
|
:alt="alt"
|
||||||
|
:class="type ? `svg-${type}` : ''"
|
||||||
|
:style="`width: ${size || 26}px;`"
|
||||||
|
:src="name.includes('https://') > 0 ? name : `/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>
|
||||||
270
app/components/TopMenu.vue
Normal file
270
app/components/TopMenu.vue
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
<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' }" />
|
||||||
|
<span class="fsb-20 has-text-primary">HRM</span>
|
||||||
|
</span>
|
||||||
|
<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="`fs-15 ${currentTab.code === v.code ? 'activetab' : ''}`"
|
||||||
|
style="font-weight: 600; 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);
|
||||||
|
// let menu = $filter($store.common, { category: 'topmenu' });
|
||||||
|
// console.log('menu', menu);
|
||||||
|
const menu = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
category: 'topmenu',
|
||||||
|
classify: 'left',
|
||||||
|
code: 'dashboard',
|
||||||
|
vi: 'Dashboard',
|
||||||
|
link: null,
|
||||||
|
detail: {
|
||||||
|
base: 'Dashboard',
|
||||||
|
component: 'DashboardMaster',
|
||||||
|
},
|
||||||
|
index: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
category: 'topmenu',
|
||||||
|
classify: 'left',
|
||||||
|
code: 'hrm',
|
||||||
|
vi: 'Quản lý nhân sự',
|
||||||
|
link: null,
|
||||||
|
detail: {
|
||||||
|
base: 'HRM',
|
||||||
|
component: 'HRMMaster',
|
||||||
|
},
|
||||||
|
index: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
category: 'topmenu',
|
||||||
|
classify: 'left',
|
||||||
|
code: 'rollcall',
|
||||||
|
vi: 'Chấm công',
|
||||||
|
link: null,
|
||||||
|
detail: {
|
||||||
|
base: 'Rollcall',
|
||||||
|
component: 'RollcallMaster',
|
||||||
|
},
|
||||||
|
index: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 4,
|
||||||
|
category: 'topmenu',
|
||||||
|
classify: 'left',
|
||||||
|
code: 'salary',
|
||||||
|
vi: 'Tính lương',
|
||||||
|
link: null,
|
||||||
|
detail: {
|
||||||
|
base: 'Salary',
|
||||||
|
component: 'SalaryMaster',
|
||||||
|
},
|
||||||
|
index: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 5,
|
||||||
|
category: 'topmenu',
|
||||||
|
classify: 'left',
|
||||||
|
code: 'kpi_efficiency',
|
||||||
|
vi: 'KPI & Hiệu suất',
|
||||||
|
link: null,
|
||||||
|
detail: {
|
||||||
|
base: 'Kpi',
|
||||||
|
component: 'KpiMaster',
|
||||||
|
},
|
||||||
|
index: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 6,
|
||||||
|
category: 'topmenu',
|
||||||
|
classify: 'left',
|
||||||
|
code: 'employee',
|
||||||
|
vi: 'Cổng nhân viên',
|
||||||
|
link: null,
|
||||||
|
detail: {
|
||||||
|
base: 'Employee',
|
||||||
|
component: 'EmployeeMaster',
|
||||||
|
},
|
||||||
|
index: 0,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
const leftmenu = $filter(menu, { category: 'topmenu', classify: 'left' });
|
||||||
|
let currentTab = ref(leftmenu.length > 0 ? leftmenu[0] : undefined);
|
||||||
|
const subTab = ref();
|
||||||
|
const tabConfig = $find(menu, { code: 'configuration' });
|
||||||
|
const avatar = ref();
|
||||||
|
const 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>
|
||||||
165
app/components/accounting/ConfirmDeleteEntry.vue
Normal file
165
app/components/accounting/ConfirmDeleteEntry.vue
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
<!-- components/dialog/ConfirmDeleteEntry.vue -->
|
||||||
|
<template>
|
||||||
|
<div class="has-text-centered">
|
||||||
|
<div v-if="isLoading" class="is-flex is-gap-2 is-align-items-center is-justify-content-center">
|
||||||
|
<SvgIcon v-bind="{ name: 'loading.svg', type: 'primary' }" />
|
||||||
|
<span>Đang tải thông tin bút toán...</span>
|
||||||
|
</div>
|
||||||
|
<template v-else-if="entry.allocation_amount === 0">
|
||||||
|
<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>
|
||||||
|
</template>
|
||||||
|
<div v-else class="has-text-centered">
|
||||||
|
<div class="fs-18">
|
||||||
|
<span>Không được phép xoá bút toán (đã phân bổ </span>
|
||||||
|
<span class="has-text-weight-semibold">
|
||||||
|
<FormatNumber :value="entry.allocation_amount" />
|
||||||
|
<span>đ</span>
|
||||||
|
</span>
|
||||||
|
<span>).</span>
|
||||||
|
</div>
|
||||||
|
<button @click="emit('close')" class="button mt-4">Quay lại</button>
|
||||||
|
</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, $getdata, $insertapi } = useNuxtApp()
|
||||||
|
const entry = ref(null);
|
||||||
|
const isLoading = ref(true);
|
||||||
|
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')
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
isLoading.value = true;
|
||||||
|
const entryData = await $getdata('internalentry', { id: props.entryId }, undefined, true);
|
||||||
|
entry.value = entryData;
|
||||||
|
isLoading.value = false;
|
||||||
|
})
|
||||||
|
</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>
|
||||||
73
app/components/accounting/DebtProduct.vue
Normal file
73
app/components/accounting/DebtProduct.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>
|
||||||
|
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) {
|
||||||
|
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>
|
||||||
605
app/components/accounting/DebtView.vue
Normal file
605
app/components/accounting/DebtView.vue
Normal file
@@ -0,0 +1,605 @@
|
|||||||
|
<template>
|
||||||
|
<div class="p-3">
|
||||||
|
<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"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
<div
|
||||||
|
v-if="filteredRows.length > 0"
|
||||||
|
class="table-container fs-15"
|
||||||
|
style="overflow-x: auto; max-width: 100%"
|
||||||
|
>
|
||||||
|
<table class="table is-bordered is-striped is-narrow is-hoverable is-fullwidth debt-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th rowspan="2" class="fixed-col has-background-primary has-text-white">Mã Căn</th>
|
||||||
|
<th rowspan="2" class="has-background-primary has-text-white">STT</th>
|
||||||
|
<th rowspan="2" class="has-background-primary has-text-white">Mã KH</th>
|
||||||
|
<th rowspan="2" class="has-background-primary has-text-white">Ngày ký HĐ</th>
|
||||||
|
<th rowspan="2" class="has-background-primary has-text-white">Giá trị HĐMB</th>
|
||||||
|
|
||||||
|
<!-- TTTHNV schedules (phase 4) -->
|
||||||
|
<th
|
||||||
|
v-for="(sch, si) in ttthnvScheduleHeaders"
|
||||||
|
:key="'ttthnv-h-' + 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">Tiền nộp theo HĐV/TTTHNV</th>
|
||||||
|
<th rowspan="2" class="has-background-primary has-text-white">Tỷ lệ</th>
|
||||||
|
|
||||||
|
<!-- HĐCN schedules (phase 3) -->
|
||||||
|
<th
|
||||||
|
v-for="(sch, si) in scheduleHeaders"
|
||||||
|
:key="'hd-h-' + si"
|
||||||
|
:colspan="7"
|
||||||
|
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>
|
||||||
|
<!-- Sub-headers TTTHNV -->
|
||||||
|
<template v-for="(sch, si) in ttthnvScheduleHeaders" :key="'ttthnv-sub-' + 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">Đã 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>
|
||||||
|
<th class="has-background-primary has-text-white sub-header">Lãi phạt</th>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Sub-headers HĐCN -->
|
||||||
|
<template v-for="(sch, si) in scheduleHeaders" :key="'hd-sub-' + 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>
|
||||||
|
<th class="has-background-primary has-text-white sub-header">Lãi phạt</th>
|
||||||
|
</template>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="(row, ri) in filteredRows" :key="ri">
|
||||||
|
<td class="fixed-col has-text-weight-semibold has-text-primary">{{ row.trade_code }}</td>
|
||||||
|
<td class="has-text-centered">{{ ri + 1 }}</td>
|
||||||
|
<td>{{ row.customer_code }}</td>
|
||||||
|
<td>{{ row.contract_date }}</td>
|
||||||
|
<td class="has-text-right">{{ fmt(row.sale_price) }}</td>
|
||||||
|
|
||||||
|
<!-- TTTHNV schedule cells -->
|
||||||
|
<template v-for="(sch, si) in ttthnvScheduleHeaders" :key="'ttthnv-row-' + si">
|
||||||
|
<template v-if="row.ttthnvSchedules[si]">
|
||||||
|
<td>{{ row.ttthnvSchedules[si].to_date }}</td>
|
||||||
|
<td class="has-text-right">{{ fmt(row.ttthnvSchedules[si].amount) }}</td>
|
||||||
|
<td class="has-text-right has-text-weight-semibold has-text-success">
|
||||||
|
{{ fmt(row.ttthnvSchedules[si].paid_amount) }}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
class="has-text-right"
|
||||||
|
:class="pctClass(row.ttthnvSchedules[si].paid_amount, row.ttthnvSchedules[si].amount)"
|
||||||
|
>
|
||||||
|
{{ pct(row.ttthnvSchedules[si].paid_amount, row.ttthnvSchedules[si].amount) }}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
class="has-text-right has-text-weight-semibold"
|
||||||
|
:class="Number(row.ttthnvSchedules[si].amount_remain) > 0 ? 'has-text-danger' : 'has-text-success'"
|
||||||
|
>
|
||||||
|
{{ fmt(row.ttthnvSchedules[si].amount_remain) }}
|
||||||
|
</td>
|
||||||
|
<td
|
||||||
|
class="has-text-right has-text-weight-semibold"
|
||||||
|
:class="Number(row.ttthnvSchedules[si].penalty_remain) > 0 ? 'has-text-danger' : ''"
|
||||||
|
>
|
||||||
|
{{ Number(row.ttthnvSchedules[si].penalty_remain) > 0 ? fmt(row.ttthnvSchedules[si].penalty_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-semibold has-background-warning-light">
|
||||||
|
{{ fmt(row.ttthnv_paid) }}
|
||||||
|
</td>
|
||||||
|
<td class="has-text-right has-background-success-light">
|
||||||
|
{{ pct(row.ttthnv_paid, row.sale_price) }}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<!-- HĐCN schedule cells -->
|
||||||
|
<template v-for="(sch, si) in scheduleHeaders" :key="'hd-row-' + 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>
|
||||||
|
<td
|
||||||
|
class="has-text-right has-text-weight-semibold"
|
||||||
|
:class="Number(row.schedules[si].penalty_remain) > 0 ? 'has-text-danger' : ''"
|
||||||
|
>
|
||||||
|
{{ Number(row.schedules[si].penalty_remain) > 0 ? fmt(row.schedules[si].penalty_remain) : '—' }}
|
||||||
|
</td>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<td colspan="7" 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="1" class="fixed-col has-text-weight-bold">TỔNG CỘNG</td>
|
||||||
|
<td :colspan="4"></td>
|
||||||
|
|
||||||
|
<!-- Footer TTTHNV -->
|
||||||
|
<template v-for="(sch, si) in ttthnvScheduleHeaders" :key="'ttthnv-foot-' + si">
|
||||||
|
<td></td>
|
||||||
|
<td class="has-text-right has-text-weight-semibold">{{ fmt(ttthnvColSum(si, 'amount')) }}</td>
|
||||||
|
<td class="has-text-right has-text-weight-semibold has-text-success">
|
||||||
|
{{ fmt(ttthnvColSum(si, 'paid_amount')) }}
|
||||||
|
</td>
|
||||||
|
<td></td>
|
||||||
|
<td class="has-text-right has-text-weight-semibold has-text-danger">
|
||||||
|
{{ fmt(ttthnvColSum(si, 'amount_remain')) }}
|
||||||
|
</td>
|
||||||
|
<td class="has-text-right has-text-weight-semibold has-text-danger">
|
||||||
|
{{ fmt(ttthnvColSum(si, 'penalty_remain')) }}
|
||||||
|
</td>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<td></td>
|
||||||
|
<td></td>
|
||||||
|
|
||||||
|
<!-- Footer HĐCN -->
|
||||||
|
<template v-for="(sch, si) in scheduleHeaders" :key="'hd-foot-' + 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>
|
||||||
|
<td class="has-text-right has-text-weight-semibold has-text-danger">
|
||||||
|
{{ fmt(colSum(si, 'penalty_remain')) }}
|
||||||
|
</td>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<td class="has-text-right has-text-weight-bold has-text-danger">
|
||||||
|
{{ fmt(totalOverdue) }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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 ttthnvScheduleHeaders = 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 = []
|
||||||
|
ttthnvScheduleHeaders.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,penalty_remain,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 ttthnvSchConn = $copy($findapi('payment_schedule'))
|
||||||
|
ttthnvSchConn.params = {
|
||||||
|
filter: { txn_detail__phase: 4 },
|
||||||
|
values: 'id,code,cycle,to_date,from_date,amount,paid_amount,amount_remain,remain_amount,penalty_remain,status,status__name,txn_detail,txn_detail__transaction,txn_detail__phase',
|
||||||
|
sort: 'txn_detail__transaction,cycle'
|
||||||
|
}
|
||||||
|
|
||||||
|
const [txnRs, detailRs, schRs, ttthnvRs, ttthnvSchRs] = await $getapi([
|
||||||
|
txnConn,
|
||||||
|
detailConn,
|
||||||
|
schConn,
|
||||||
|
ttthnvConn,
|
||||||
|
ttthnvSchConn
|
||||||
|
])
|
||||||
|
|
||||||
|
const transactions = txnRs?.data?.rows || []
|
||||||
|
const schedules = schRs?.data?.rows || []
|
||||||
|
const ttthnvList = ttthnvRs?.data?.rows || []
|
||||||
|
const ttthnvSchedules = ttthnvSchRs?.data?.rows || []
|
||||||
|
|
||||||
|
if (!transactions.length) {
|
||||||
|
loading.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const ttthnvMap = {}
|
||||||
|
ttthnvList.forEach(t => {
|
||||||
|
const tid = t.transaction
|
||||||
|
ttthnvMap[tid] = (ttthnvMap[tid] || 0) + Number(t.amount_received || 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
const schByTxn = {}
|
||||||
|
schedules.forEach(s => {
|
||||||
|
const tid = s.txn_detail__transaction
|
||||||
|
if (!schByTxn[tid]) schByTxn[tid] = []
|
||||||
|
schByTxn[tid].push(s)
|
||||||
|
})
|
||||||
|
|
||||||
|
const ttthnvSchByTxn = {}
|
||||||
|
ttthnvSchedules.forEach(s => {
|
||||||
|
const tid = s.txn_detail__transaction
|
||||||
|
if (!ttthnvSchByTxn[tid]) ttthnvSchByTxn[tid] = []
|
||||||
|
ttthnvSchByTxn[tid].push(s)
|
||||||
|
})
|
||||||
|
|
||||||
|
let maxCycles = 0
|
||||||
|
Object.values(schByTxn).forEach(list => {
|
||||||
|
const n = list.filter(s => Number(s.cycle) > 0).length
|
||||||
|
if (n > maxCycles) maxCycles = n
|
||||||
|
})
|
||||||
|
|
||||||
|
let maxTtthnvCycles = 0
|
||||||
|
Object.values(ttthnvSchByTxn).forEach(list => {
|
||||||
|
const n = list.filter(s => Number(s.cycle) > 0).length
|
||||||
|
if (n > maxTtthnvCycles) maxTtthnvCycles = n
|
||||||
|
})
|
||||||
|
|
||||||
|
scheduleHeaders.value = Array.from({ length: maxCycles }, (_, i) => ({
|
||||||
|
label: `L0${i + 1}`,
|
||||||
|
index: i
|
||||||
|
}))
|
||||||
|
|
||||||
|
ttthnvScheduleHeaders.value = Array.from({ length: maxTtthnvCycles }, (_, i) => ({
|
||||||
|
label: `TTTHNV thanh toán đợt ${String(i + 1).padStart(2, '0')}`,
|
||||||
|
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)
|
||||||
|
const luyKe = ttthnvPaid
|
||||||
|
|
||||||
|
let remainingTTTHNV = ttthnvPaid
|
||||||
|
|
||||||
|
const schedulesWithCalc = txnSchedules.map(sch => {
|
||||||
|
const scheduleAmount = Number(sch.amount || 0)
|
||||||
|
const luyKeSangDot = Math.min(remainingTTTHNV, scheduleAmount)
|
||||||
|
const paidAmountFromSchedule = Number(sch.paid_amount || 0)
|
||||||
|
const thucThanhToan = Math.max(0, paidAmountFromSchedule - luyKeSangDot)
|
||||||
|
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,
|
||||||
|
penalty_remain: Number(sch.penalty_remain || 0),
|
||||||
|
status: sch.status
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const txnTtthnvSchedules = (ttthnvSchByTxn[txn.id] || [])
|
||||||
|
.filter(s => Number(s.cycle) > 0)
|
||||||
|
.sort((a, b) => Number(a.cycle) - Number(b.cycle))
|
||||||
|
.map(sch => ({
|
||||||
|
to_date: sch.to_date ? $dayjs(sch.to_date).format('DD/MM/YYYY') : '—',
|
||||||
|
amount: Number(sch.amount || 0),
|
||||||
|
paid_amount: Number(sch.paid_amount || 0),
|
||||||
|
amount_remain: Number(sch.amount_remain ?? sch.remain_amount ?? 0),
|
||||||
|
penalty_remain: Number(sch.penalty_remain || 0)
|
||||||
|
}))
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
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,
|
||||||
|
schedules: Array.from({ length: maxCycles }, (_, i) => schedulesWithCalc[i] || null),
|
||||||
|
ttthnvSchedules: Array.from({ length: maxTtthnvCycles }, (_, i) => txnTtthnvSchedules[i] || null),
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ttthnvColSum(scheduleIndex, field) {
|
||||||
|
return filteredRows.value.reduce((sum, row) => {
|
||||||
|
const sch = row.ttthnvSchedules[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'
|
||||||
|
]
|
||||||
|
|
||||||
|
ttthnvScheduleHeaders.value.forEach(h => {
|
||||||
|
headers.push(
|
||||||
|
`${h.label} - Ngày`, `${h.label} - Số tiền`,
|
||||||
|
`${h.label} - Đã thanh toán`, `${h.label} - Tỷ lệ`,
|
||||||
|
`${h.label} - Dư nợ`, `${h.label} - Lãi phạt`
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
headers.push('Tiền nộp TTTHNV', 'Tỷ lệ')
|
||||||
|
|
||||||
|
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ợ`, `${h.label} - Lãi phạt`
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
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)]
|
||||||
|
|
||||||
|
ttthnvScheduleHeaders.value.forEach((_, si) => {
|
||||||
|
const sch = row.ttthnvSchedules[si]
|
||||||
|
if (sch) {
|
||||||
|
base.push(
|
||||||
|
sch.to_date, fmt(sch.amount), fmt(sch.paid_amount),
|
||||||
|
pct(sch.paid_amount, sch.amount), fmt(sch.amount_remain), fmt(sch.penalty_remain)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
base.push('', '', '', '', '', '')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
base.push(fmt(row.ttthnv_paid), pct(row.ttthnv_paid, 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), fmt(sch.penalty_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-col {
|
||||||
|
position: sticky;
|
||||||
|
left: 0;
|
||||||
|
z-index: 3;
|
||||||
|
background-color: white;
|
||||||
|
min-width: 80px;
|
||||||
|
}
|
||||||
|
.fixed-col:nth-child(1) { left: 0; min-width: 120px; z-index: 4; }
|
||||||
|
.fixed-col:nth-child(2) { left: 50px; min-width: 100px; }
|
||||||
|
.fixed-col:nth-child(3) { left: 150px; min-width: 120px; }
|
||||||
|
.fixed-col:nth-child(4) { left: 270px; min-width: 110px; }
|
||||||
|
.fixed-col:nth-child(5) { left: 380px; min-width: 140px; }
|
||||||
|
.fixed-col:nth-child(6) { left: 520px; min-width: 160px; }
|
||||||
|
.fixed-col:nth-child(7) { left: 680px; min-width: 160px; }
|
||||||
|
.fixed-col:nth-child(8) { left: 840px; min-width: 90px; border-right: 2px solid #dee2e6 !important; }
|
||||||
|
.debt-table thead .fixed-col { position: sticky; top: 0; z-index: 5; background-color: #204853 !important; color: white !important; }
|
||||||
|
.debt-table tfoot .fixed-col { background-color: #f8f9fa !important; font-weight: bold; }
|
||||||
|
.debt-table tfoot tr { z-index: 2; }
|
||||||
|
.fixed-col { box-shadow: 4px 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>
|
||||||
248
app/components/accounting/InternalDeposit.vue
Normal file
248
app/components/accounting/InternalDeposit.vue
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
<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-8" v-if="record._category && record._category.id === 1">
|
||||||
|
<div class="field">
|
||||||
|
<label class="label">Ngân hàng<b class="ml-1 has-text-danger">*</b></label>
|
||||||
|
<div class="control">
|
||||||
|
<input class="input has-text-black" type="text" v-model="record.bank">
|
||||||
|
</div>
|
||||||
|
<p class="help is-danger" v-if="errors.bank">{{ errors.bank }}</p>
|
||||||
|
</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) {
|
||||||
|
// Khi đổi category, reset trường bank nếu category không phải id=1
|
||||||
|
if (attr === '_category' && obj && obj.id !== 1) {
|
||||||
|
this.record.bank = undefined
|
||||||
|
}
|
||||||
|
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'
|
||||||
|
// Validate bank nếu category.id === 1
|
||||||
|
if(this.record._category && this.record._category.id === 1 && this.$empty(this.record.bank)) {
|
||||||
|
this.errors.bank = 'Chưa nhập tên ngân hàng'
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
bank: (this.record._category && this.record._category.id === 1) ? (this.record.bank || null) : null,
|
||||||
|
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
|
||||||
|
});
|
||||||
|
|
||||||
|
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>
|
||||||
291
app/components/accounting/TransactionInvoice.vue
Normal file
291
app/components/accounting/TransactionInvoice.vue
Normal file
@@ -0,0 +1,291 @@
|
|||||||
|
<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"
|
||||||
|
class="w-full"
|
||||||
|
style="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>
|
||||||
20
app/components/application/ApplicationImage.vue
Normal file
20
app/components/application/ApplicationImage.vue
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<template>
|
||||||
|
<ImageGallery
|
||||||
|
v-bind="{ row, pagename, show, api: 'applicationfile' }"
|
||||||
|
@remove="emit('remove')"
|
||||||
|
@update="update"
|
||||||
|
></ImageGallery>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import ImageGallery from "../media/ImageGallery.vue";
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
row: Object,
|
||||||
|
pagename: String,
|
||||||
|
api: String,
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(["remove"]);
|
||||||
|
|
||||||
|
</script>
|
||||||
1058
app/components/application/CalculationView.vue
Normal file
1058
app/components/application/CalculationView.vue
Normal file
File diff suppressed because it is too large
Load Diff
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>
|
||||||
509
app/components/application/ContractPaymentUpload.vue
Normal file
509
app/components/application/ContractPaymentUpload.vue
Normal file
@@ -0,0 +1,509 @@
|
|||||||
|
<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="is-flex is-align-items-center is-gap-3">
|
||||||
|
<div class="buttons has-addons mb-0">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<button v-if="$getEditRights() && currentReservation?.status === 5 && supplementNotes.length > 0"
|
||||||
|
class="button is-danger is-light" @click="showSupplementNotes">
|
||||||
|
<span>Thông tin thiếu ({{ supplementNotes.length }})</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button v-if="$getEditRights() && currentReservation?.status === 5" class="button is-warning"
|
||||||
|
:class="{ 'is-loading': isSubmitting }" :disabled="isSubmitting" @click="confirmSubmitForApproval">
|
||||||
|
Xác nhận đã hoàn thành bổ sung thông tin
|
||||||
|
</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">
|
||||||
|
<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 h-full"
|
||||||
|
style="border-radius: 6px; overflow: hidden; 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)"
|
||||||
|
class="w-full h-full is-flex is-justify-content-center is-align-items-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>
|
||||||
|
|
||||||
|
<!-- Supplement Notes Modal -->
|
||||||
|
<div v-if="showNotesModal" class="modal is-active">
|
||||||
|
<div class="modal-background" @click="showNotesModal = false"></div>
|
||||||
|
<div class="modal-card" style="width: 600px; max-width: 90vw;">
|
||||||
|
<header class="modal-card-head">
|
||||||
|
<p class="modal-card-title">Yêu cầu bổ sung thông tin</p>
|
||||||
|
<button class="delete" @click="showNotesModal = false"></button>
|
||||||
|
</header>
|
||||||
|
<section class="modal-card-body">
|
||||||
|
<div v-for="(note, idx) in supplementNotes" :key="idx" class="notification is-warning is-light mb-3">
|
||||||
|
<p class="is-size-7 has-text-grey mb-1">
|
||||||
|
{{ $dayjs(note.create_time).format('DD/MM/YYYY HH:mm') }}
|
||||||
|
</p>
|
||||||
|
<p>{{ note.detail }}</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<footer class="modal-card-foot">
|
||||||
|
<button class="button" @click="showNotesModal = false">Đóng</button>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</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,
|
||||||
|
currentReservation: null,
|
||||||
|
productNotes: [],
|
||||||
|
files: [],
|
||||||
|
isLoading: false,
|
||||||
|
isSubmitting: false,
|
||||||
|
showmodal: undefined,
|
||||||
|
showNotesModal: false,
|
||||||
|
phasedoctypes: [],
|
||||||
|
viewMode: 'list',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
supplementNotes() {
|
||||||
|
return this.productNotes.filter(note =>
|
||||||
|
note.detail &&
|
||||||
|
note.detail.includes('Yêu cầu bổ sung thông tin cho giao dịch') &&
|
||||||
|
!note.detail.includes('(đã bổ sung)')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
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?.txncurrent__detail) {
|
||||||
|
this.currentReservation = await $getdata('reservation', {
|
||||||
|
id: this.transaction.txncurrent__detail
|
||||||
|
}, undefined, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.transaction?.product) {
|
||||||
|
await this.fetchProductNotes();
|
||||||
|
}
|
||||||
|
|
||||||
|
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 fetchProductNotes() {
|
||||||
|
const { $getdata } = useNuxtApp();
|
||||||
|
if (!this.transaction?.product) return;
|
||||||
|
try {
|
||||||
|
const notes = await $getdata('productnote', { ref: this.transaction.product }, undefined, false);
|
||||||
|
this.productNotes = Array.isArray(notes) ? notes : (notes ? [notes] : []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Lỗi khi tải product notes:", error);
|
||||||
|
this.productNotes = [];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
showSupplementNotes() {
|
||||||
|
this.showNotesModal = true;
|
||||||
|
},
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
confirmSubmitForApproval() {
|
||||||
|
this.showmodal = {
|
||||||
|
component: 'dialog/Confirm',
|
||||||
|
title: 'Xác nhận hoàn thành bổ sung',
|
||||||
|
height: '10vh',
|
||||||
|
width: '40%',
|
||||||
|
vbind: {
|
||||||
|
content: 'Bạn có chắc chắn đã hoàn thành bổ sung thông tin và muốn nộp lại để chờ duyệt không?'
|
||||||
|
},
|
||||||
|
onConfirm: () => this.submitForApproval()
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
async submitForApproval() {
|
||||||
|
if (this.isSubmitting) return;
|
||||||
|
this.isSubmitting = true;
|
||||||
|
const { $patchapi, $getdata, $snackbar, $updateapi } = useNuxtApp();
|
||||||
|
try {
|
||||||
|
// Cập nhật tất cả note chưa có "(đã bổ sung)" → thêm vào cuối
|
||||||
|
const notesToUpdate = this.productNotes.filter(note =>
|
||||||
|
note.detail &&
|
||||||
|
note.detail.includes('Yêu cầu bổ sung thông tin cho giao dịch') &&
|
||||||
|
!note.detail.includes('(đã bổ sung)')
|
||||||
|
);
|
||||||
|
|
||||||
|
await Promise.all(notesToUpdate.map(note =>
|
||||||
|
$updateapi('productnote', {
|
||||||
|
...note,
|
||||||
|
detail: note.detail + ' (đã bổ sung)'
|
||||||
|
})
|
||||||
|
));
|
||||||
|
|
||||||
|
// Patch reservation status về 2
|
||||||
|
await $patchapi('reservation', {
|
||||||
|
id: this.currentReservation.id,
|
||||||
|
status: 2
|
||||||
|
});
|
||||||
|
|
||||||
|
// Refresh
|
||||||
|
this.currentReservation = await $getdata('reservation', {
|
||||||
|
id: this.transaction.txncurrent__detail
|
||||||
|
}, undefined, true);
|
||||||
|
await this.fetchProductNotes();
|
||||||
|
|
||||||
|
$snackbar('Xác nhận hoàn thành bổ sung thông tin thành công', 'Success', 'Success');
|
||||||
|
this.$emit('status-updated');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Lỗi khi cập nhật trạng thái:', error);
|
||||||
|
$snackbar('Lỗi khi cập nhật trạng thái', 'Error', 'Danger');
|
||||||
|
} finally {
|
||||||
|
this.isSubmitting = 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) {
|
||||||
|
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>
|
||||||
880
app/components/application/PaymentSchedule.vue
Normal file
880
app/components/application/PaymentSchedule.vue
Normal file
@@ -0,0 +1,880 @@
|
|||||||
|
<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() {
|
||||||
|
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>
|
||||||
1274
app/components/application/PhaseAdvance.vue
Normal file
1274
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>
|
||||||
32
app/components/common/Avatarbox.vue
Normal file
32
app/components/common/Avatarbox.vue
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<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>
|
||||||
24
app/components/common/ProductCountbox.vue
Normal file
24
app/components/common/ProductCountbox.vue
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<script setup>
|
||||||
|
// use in Khách hàng -> Giao dịch (<DataView :setting='customer-all-transaction'/>)
|
||||||
|
const props = defineProps({
|
||||||
|
row: Object,
|
||||||
|
api: String,
|
||||||
|
pagename: String,
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['open']);
|
||||||
|
|
||||||
|
function onClick() {
|
||||||
|
const modal = {
|
||||||
|
component: 'common/ProductInfo',
|
||||||
|
title: 'Sản phẩm',
|
||||||
|
width: '60%',
|
||||||
|
height: '400px',
|
||||||
|
vbind: { row: props.row, pagename: props.pagename }
|
||||||
|
}
|
||||||
|
emit('open', { name: 'dataevent', data: { modal } });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<span class="dot-primary" @click="onClick()">{{ row.count_product }}</span>
|
||||||
|
</template>
|
||||||
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>
|
||||||
393
app/components/common/TextEditor.vue
Normal file
393
app/components/common/TextEditor.vue
Normal file
@@ -0,0 +1,393 @@
|
|||||||
|
<template>
|
||||||
|
<ClientOnly>
|
||||||
|
<div ref="el"></div>
|
||||||
|
</ClientOnly>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, onMounted, onBeforeUnmount } from 'vue';
|
||||||
|
import 'ckeditor5/ckeditor5.css';
|
||||||
|
import 'ckeditor5-premium-features/ckeditor5-premium-features.css';
|
||||||
|
|
||||||
|
const el = ref(null);
|
||||||
|
let editor = null;
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const emit = defineEmits(['update:modelValue']);
|
||||||
|
const content = ref(props.modelValue || '');
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
if (!process.client) return;
|
||||||
|
|
||||||
|
const {
|
||||||
|
ClassicEditor,
|
||||||
|
AccessibilityHelp,
|
||||||
|
Autoformat,
|
||||||
|
AutoImage,
|
||||||
|
AutoLink,
|
||||||
|
Autosave,
|
||||||
|
BalloonToolbar,
|
||||||
|
BlockQuote,
|
||||||
|
BlockToolbar,
|
||||||
|
Bold,
|
||||||
|
CKBox,
|
||||||
|
CKBoxImageEdit,
|
||||||
|
CloudServices,
|
||||||
|
Essentials,
|
||||||
|
FontBackgroundColor,
|
||||||
|
FontColor,
|
||||||
|
FontFamily,
|
||||||
|
FontSize,
|
||||||
|
Heading,
|
||||||
|
Highlight,
|
||||||
|
ImageBlock,
|
||||||
|
ImageCaption,
|
||||||
|
ImageInline,
|
||||||
|
ImageInsert,
|
||||||
|
ImageInsertViaUrl,
|
||||||
|
ImageResize,
|
||||||
|
ImageStyle,
|
||||||
|
ImageTextAlternative,
|
||||||
|
ImageToolbar,
|
||||||
|
ImageUpload,
|
||||||
|
Indent,
|
||||||
|
IndentBlock,
|
||||||
|
Italic,
|
||||||
|
Link,
|
||||||
|
LinkImage,
|
||||||
|
List,
|
||||||
|
ListProperties,
|
||||||
|
MediaEmbed,
|
||||||
|
Mention,
|
||||||
|
PageBreak,
|
||||||
|
Paragraph,
|
||||||
|
PasteFromOffice,
|
||||||
|
PictureEditing,
|
||||||
|
RemoveFormat,
|
||||||
|
SelectAll,
|
||||||
|
SpecialCharacters,
|
||||||
|
SpecialCharactersArrows,
|
||||||
|
SpecialCharactersCurrency,
|
||||||
|
SpecialCharactersEssentials,
|
||||||
|
SpecialCharactersLatin,
|
||||||
|
SpecialCharactersMathematical,
|
||||||
|
SpecialCharactersText,
|
||||||
|
Subscript,
|
||||||
|
Superscript,
|
||||||
|
Table,
|
||||||
|
TableCaption,
|
||||||
|
TableCellProperties,
|
||||||
|
GeneralHtmlSupport,
|
||||||
|
TableColumnResize,
|
||||||
|
TableProperties,
|
||||||
|
TableToolbar,
|
||||||
|
TextTransformation,
|
||||||
|
TodoList,
|
||||||
|
Underline,
|
||||||
|
Alignment,
|
||||||
|
Undo,
|
||||||
|
} = await import('ckeditor5');
|
||||||
|
const { ExportPdf, ExportWord, ImportWord } = await import('ckeditor5-premium-features');
|
||||||
|
|
||||||
|
editor = await ClassicEditor.create(el.value, {
|
||||||
|
// licenseKey: 'GPL',
|
||||||
|
|
||||||
|
plugins: [
|
||||||
|
AccessibilityHelp,
|
||||||
|
Autoformat,
|
||||||
|
AutoImage,
|
||||||
|
AutoLink,
|
||||||
|
Autosave,
|
||||||
|
BalloonToolbar,
|
||||||
|
BlockQuote,
|
||||||
|
BlockToolbar,
|
||||||
|
Bold,
|
||||||
|
CKBox,
|
||||||
|
CKBoxImageEdit,
|
||||||
|
CloudServices,
|
||||||
|
Essentials,
|
||||||
|
ExportPdf,
|
||||||
|
ExportWord,
|
||||||
|
FontBackgroundColor,
|
||||||
|
FontColor,
|
||||||
|
FontFamily,
|
||||||
|
FontSize,
|
||||||
|
Heading,
|
||||||
|
Highlight,
|
||||||
|
ImageBlock,
|
||||||
|
ImageCaption,
|
||||||
|
ImageInline,
|
||||||
|
ImageInsert,
|
||||||
|
ImageInsertViaUrl,
|
||||||
|
ImageResize,
|
||||||
|
ImageStyle,
|
||||||
|
ImageTextAlternative,
|
||||||
|
ImageToolbar,
|
||||||
|
ImageUpload,
|
||||||
|
ImportWord,
|
||||||
|
Indent,
|
||||||
|
IndentBlock,
|
||||||
|
Italic,
|
||||||
|
Link,
|
||||||
|
LinkImage,
|
||||||
|
List,
|
||||||
|
ListProperties,
|
||||||
|
MediaEmbed,
|
||||||
|
Mention,
|
||||||
|
PageBreak,
|
||||||
|
Paragraph,
|
||||||
|
PasteFromOffice,
|
||||||
|
PictureEditing,
|
||||||
|
RemoveFormat,
|
||||||
|
SelectAll,
|
||||||
|
SpecialCharacters,
|
||||||
|
SpecialCharactersArrows,
|
||||||
|
SpecialCharactersCurrency,
|
||||||
|
SpecialCharactersEssentials,
|
||||||
|
SpecialCharactersLatin,
|
||||||
|
SpecialCharactersMathematical,
|
||||||
|
SpecialCharactersText,
|
||||||
|
Subscript,
|
||||||
|
Superscript,
|
||||||
|
Table,
|
||||||
|
TableCaption,
|
||||||
|
TableCellProperties,
|
||||||
|
TableColumnResize,
|
||||||
|
TableProperties,
|
||||||
|
TableToolbar,
|
||||||
|
TextTransformation,
|
||||||
|
TodoList,
|
||||||
|
Underline,
|
||||||
|
Alignment,
|
||||||
|
GeneralHtmlSupport,
|
||||||
|
Undo,
|
||||||
|
],
|
||||||
|
|
||||||
|
toolbar: [
|
||||||
|
'undo',
|
||||||
|
'redo',
|
||||||
|
'|',
|
||||||
|
'importWord',
|
||||||
|
'exportWord',
|
||||||
|
'exportPdf',
|
||||||
|
'|',
|
||||||
|
'heading',
|
||||||
|
'|',
|
||||||
|
'fontSize',
|
||||||
|
'fontFamily',
|
||||||
|
'fontColor',
|
||||||
|
'fontBackgroundColor',
|
||||||
|
'|',
|
||||||
|
'bold',
|
||||||
|
'italic',
|
||||||
|
'underline',
|
||||||
|
'subscript',
|
||||||
|
'superscript',
|
||||||
|
'removeFormat',
|
||||||
|
'|',
|
||||||
|
'alignment', // 👈 THÊM
|
||||||
|
'|',
|
||||||
|
'specialCharacters',
|
||||||
|
'pageBreak',
|
||||||
|
'link',
|
||||||
|
'insertImage',
|
||||||
|
'insertImageViaUrl',
|
||||||
|
'ckbox',
|
||||||
|
'mediaEmbed',
|
||||||
|
'insertTable',
|
||||||
|
'highlight',
|
||||||
|
'blockQuote',
|
||||||
|
'|',
|
||||||
|
'bulletedList',
|
||||||
|
'numberedList',
|
||||||
|
'todoList',
|
||||||
|
'outdent',
|
||||||
|
'indent',
|
||||||
|
],
|
||||||
|
exportPdf: {
|
||||||
|
stylesheets: [
|
||||||
|
'https://cdn.ckeditor.com/ckeditor5/43.2.0/ckeditor5.css',
|
||||||
|
'https://cdn.ckeditor.com/ckeditor5-premium-features/43.2.0/ckeditor5-premium-features.css',
|
||||||
|
],
|
||||||
|
fileName: 'export-pdf-demo.pdf',
|
||||||
|
converterOptions: {
|
||||||
|
format: 'Tabloid',
|
||||||
|
margin_top: '20mm',
|
||||||
|
margin_bottom: '20mm',
|
||||||
|
margin_right: '24mm',
|
||||||
|
margin_left: '24mm',
|
||||||
|
page_orientation: 'portrait',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
exportWord: {
|
||||||
|
stylesheets: [
|
||||||
|
'https://cdn.ckeditor.com/ckeditor5/43.2.0/ckeditor5.css',
|
||||||
|
'https://cdn.ckeditor.com/ckeditor5-premium-features/43.2.0/ckeditor5-premium-features.css',
|
||||||
|
],
|
||||||
|
fileName: 'export-word-demo.docx',
|
||||||
|
converterOptions: {
|
||||||
|
document: {
|
||||||
|
orientation: 'portrait',
|
||||||
|
size: 'Tabloid',
|
||||||
|
margins: {
|
||||||
|
top: '20mm',
|
||||||
|
bottom: '20mm',
|
||||||
|
right: '24mm',
|
||||||
|
left: '24mm',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
balloonToolbar: ['bold', 'italic','fontFamily','fontSize', '|', 'alignment', 'link', 'insertImage', '|', 'bulletedList', 'numberedList'],
|
||||||
|
blockToolbar: [
|
||||||
|
'fontSize',
|
||||||
|
'fontColor',
|
||||||
|
'fontBackgroundColor',
|
||||||
|
'|',
|
||||||
|
'bold',
|
||||||
|
'italic',
|
||||||
|
'|',
|
||||||
|
'link',
|
||||||
|
'insertImage',
|
||||||
|
'insertTable',
|
||||||
|
'|',
|
||||||
|
'bulletedList',
|
||||||
|
'numberedList',
|
||||||
|
'outdent',
|
||||||
|
'indent',
|
||||||
|
],
|
||||||
|
fontFamily: {
|
||||||
|
supportAllValues: true,
|
||||||
|
},
|
||||||
|
fontSize: {
|
||||||
|
options: [10, 12,13, 14, 'default', 18, 20, 22],
|
||||||
|
supportAllValues: true,
|
||||||
|
},
|
||||||
|
heading: {
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
model: 'paragraph',
|
||||||
|
title: 'Paragraph',
|
||||||
|
class: 'ck-heading_paragraph',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: 'heading1',
|
||||||
|
view: 'h1',
|
||||||
|
title: 'Heading 1',
|
||||||
|
class: 'ck-heading_heading1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: 'heading2',
|
||||||
|
view: 'h2',
|
||||||
|
title: 'Heading 2',
|
||||||
|
class: 'ck-heading_heading2',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: 'heading3',
|
||||||
|
view: 'h3',
|
||||||
|
title: 'Heading 3',
|
||||||
|
class: 'ck-heading_heading3',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: 'heading4',
|
||||||
|
view: 'h4',
|
||||||
|
title: 'Heading 4',
|
||||||
|
class: 'ck-heading_heading4',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: 'heading5',
|
||||||
|
view: 'h5',
|
||||||
|
title: 'Heading 5',
|
||||||
|
class: 'ck-heading_heading5',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model: 'heading6',
|
||||||
|
view: 'h6',
|
||||||
|
title: 'Heading 6',
|
||||||
|
class: 'ck-heading_heading6',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
image: {
|
||||||
|
toolbar: [
|
||||||
|
'toggleImageCaption',
|
||||||
|
'imageTextAlternative',
|
||||||
|
'|',
|
||||||
|
'imageStyle:inline',
|
||||||
|
'imageStyle:wrapText',
|
||||||
|
'imageStyle:breakText',
|
||||||
|
'|',
|
||||||
|
'resizeImage',
|
||||||
|
'|',
|
||||||
|
'ckboxImageEdit',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
link: {
|
||||||
|
addTargetToExternalLinks: true,
|
||||||
|
defaultProtocol: 'https://',
|
||||||
|
decorators: {
|
||||||
|
toggleDownloadable: {
|
||||||
|
mode: 'manual',
|
||||||
|
label: 'Downloadable',
|
||||||
|
attributes: {
|
||||||
|
download: 'file',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
list: {
|
||||||
|
properties: {
|
||||||
|
styles: true,
|
||||||
|
startIndex: true,
|
||||||
|
reversed: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mention: {
|
||||||
|
feeds: [
|
||||||
|
{
|
||||||
|
marker: '@',
|
||||||
|
feed: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
menuBar: {
|
||||||
|
isVisible: true,
|
||||||
|
},
|
||||||
|
table: {
|
||||||
|
contentToolbar: ['tableColumn', 'tableRow', 'mergeTableCells', 'tableProperties', 'tableCellProperties'],
|
||||||
|
},
|
||||||
|
alignment: {
|
||||||
|
options: ['left', 'center', 'right', 'justify'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// set data ban đầu
|
||||||
|
editor.setData(props.modelValue || '');
|
||||||
|
|
||||||
|
// khi thay đổi → emit ra ngoài
|
||||||
|
editor.model.document.on('change:data', () => {
|
||||||
|
const data = editor.getData();
|
||||||
|
emit('update:modelValue', data);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(val) => {
|
||||||
|
if (editor && val !== editor.getData()) {
|
||||||
|
editor.setData(val || '');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
onBeforeUnmount(async () => {
|
||||||
|
if (editor) {
|
||||||
|
await editor.destroy();
|
||||||
|
editor = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</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>
|
||||||
782
app/components/customer/CustomerInfo.vue
Normal file
782
app/components/customer/CustomerInfo.vue
Normal file
@@ -0,0 +1,782 @@
|
|||||||
|
<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 } 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,
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
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>
|
||||||
693
app/components/datatable/DataTable.vue
Normal file
693
app/components/datatable/DataTable.vue
Normal file
@@ -0,0 +1,693 @@
|
|||||||
|
<template>
|
||||||
|
<div class="field is-grouped is-grouped-multiline pl-2" v-if="filters?.length > 0">
|
||||||
|
<div class="control mr-5">
|
||||||
|
<a class="button is-primary is-small has-text-white has-text-weight-bold"
|
||||||
|
@click="updateData({ filters: [] })">Xóa lọc</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.select
|
||||||
|
? '[' + (v.select[0] ? $stripHtml(v.select[0], 20) : '') + '…' + v.select.length + ']'
|
||||||
|
: v.condition) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="table-container mb-0 dtp-wrap" ref="container">
|
||||||
|
<table class="table is-fullwidth is-bordered is-narrow is-hoverable dtp-table" :style="tableStyle">
|
||||||
|
|
||||||
|
<!-- ═══════════ THEAD ═══════════ -->
|
||||||
|
<thead>
|
||||||
|
|
||||||
|
<!-- ROW 1: group headers (chỉ render nếu có ít nhất 1 field có groupHeader) -->
|
||||||
|
<tr v-if="hasGroupHeaders" class="dtp-header-group">
|
||||||
|
<th
|
||||||
|
v-for="(col, i) in groupHeaderRow"
|
||||||
|
:key="i"
|
||||||
|
:colspan="col.colspan"
|
||||||
|
:rowspan="col.rowspan"
|
||||||
|
:style="col.style"
|
||||||
|
:class="['dtp-th-group', col.isGroup ? 'dtp-th-group--label' : 'dtp-th-group--empty']"
|
||||||
|
>
|
||||||
|
<div v-if="col.isGroup">{{ col.label }}</div>
|
||||||
|
<div v-else :style="col.dropStyle" @click="showField(col.field)">
|
||||||
|
<a v-if="col.label?.indexOf('<') < 0">{{ col.label }}</a>
|
||||||
|
<a v-else>
|
||||||
|
<component :is="dynamicComponent(col.label)" :row="{}"
|
||||||
|
@clickevent="clickEvent($event, {}, col.field)" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
<!-- ROW 2: chỉ render fields CÓ groupHeader (field không có groupHeader đã rowspan=2 ở Row 1) -->
|
||||||
|
<tr class="dtp-header-fields" v-if="hasGroupHeaders">
|
||||||
|
<th
|
||||||
|
v-for="(field, i) in displayFields.filter(f => f.groupHeader)"
|
||||||
|
:key="i"
|
||||||
|
:style="field.headerStyle"
|
||||||
|
:class="{ 'dtp-th--sorted': isSorted(field) }"
|
||||||
|
@click="showField(field)"
|
||||||
|
>
|
||||||
|
<div :style="field.dropStyle">
|
||||||
|
<a v-if="field.label?.indexOf('<') < 0">{{ field.label }}</a>
|
||||||
|
<a v-else>
|
||||||
|
<component :is="dynamicComponent(field.label)" :row="{}"
|
||||||
|
@clickevent="clickEvent($event, {}, field)" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
<!-- ROW đơn (không có groupHeader nào): render bình thường -->
|
||||||
|
<tr class="dtp-header-fields" v-else>
|
||||||
|
<th
|
||||||
|
v-for="(field, i) in displayFields"
|
||||||
|
:key="i"
|
||||||
|
:style="field.headerStyle"
|
||||||
|
:class="{ 'dtp-th--sorted': isSorted(field) }"
|
||||||
|
@click="showField(field)"
|
||||||
|
>
|
||||||
|
<div :style="field.dropStyle">
|
||||||
|
<a v-if="field.label?.indexOf('<') < 0">{{ field.label }}</a>
|
||||||
|
<a v-else>
|
||||||
|
<component :is="dynamicComponent(field.label)" :row="{}"
|
||||||
|
@clickevent="clickEvent($event, {}, field)" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
<!-- ═══════════ TBODY ═══════════ -->
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="(v, i) in displayData" :key="i">
|
||||||
|
<td
|
||||||
|
v-for="(field, j) in displayFields"
|
||||||
|
:key="j"
|
||||||
|
:style="tdStyle(field, v)"
|
||||||
|
:class="[
|
||||||
|
'dtp-td',
|
||||||
|
field.split === 'stack' ? 'dtp-td--stack' : '',
|
||||||
|
field.split === 'columns' ? 'dtp-td--columns' : '',
|
||||||
|
field.textalign === 'right' ? 'dtp-td--right' : '',
|
||||||
|
]"
|
||||||
|
@dblclick="doubleClick(field, v)"
|
||||||
|
>
|
||||||
|
<!-- ── Custom template ── -->
|
||||||
|
<component
|
||||||
|
v-if="field.template"
|
||||||
|
:is="dynamicComponent(field.template)"
|
||||||
|
:row="v"
|
||||||
|
@clickevent="clickEvent($event, v, field)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- ── split: "stack" ── -->
|
||||||
|
<template v-else-if="field.split === 'stack'">
|
||||||
|
<div class="dtp-stack">
|
||||||
|
<template v-if="v[field.name]?.length > 0">
|
||||||
|
<div
|
||||||
|
class="dtp-stack-row"
|
||||||
|
v-for="(item, si) in v[field.name]"
|
||||||
|
:key="si"
|
||||||
|
>
|
||||||
|
<!-- splitExpression: "{inv_amount} - {plan_amount}" → tính toán số học -->
|
||||||
|
<span v-if="field.splitExpression">
|
||||||
|
<CellValue
|
||||||
|
:value="evalSplitExpression(field.splitExpression, item)"
|
||||||
|
:type="field.splitType || 'number'"
|
||||||
|
:align="field.textalign"
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<!-- splitTemplate: "Đợt {cycle} - {code}" → ghép nhiều field -->
|
||||||
|
<span v-else-if="field.splitTemplate" class="dtp-split-tpl">
|
||||||
|
{{ resolveSplitTemplate(field.splitTemplate, item) }}
|
||||||
|
</span>
|
||||||
|
<CellValue
|
||||||
|
v-else
|
||||||
|
:value="item[field.splitField]"
|
||||||
|
:type="field.splitType || detectType(item[field.splitField])"
|
||||||
|
:align="field.textalign"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<div v-else class="dtp-stack-row dtp-stack-row--empty">
|
||||||
|
<span class="dtp-null">—</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- ── split: "columns" ── -->
|
||||||
|
<template v-else-if="field.split === 'columns'">
|
||||||
|
<div v-if="!v[field.name] || v[field.name].length === 0"
|
||||||
|
class="dtp-cols-empty">Không có dữ liệu</div>
|
||||||
|
<table v-else class="dtp-minitable">
|
||||||
|
<thead v-if="field.showSubHeader !== false">
|
||||||
|
<tr>
|
||||||
|
<th v-for="(sf, si) in resolveSubfields(field, v[field.name])" :key="si">
|
||||||
|
{{ sf.label }}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="(item, si) in v[field.name]" :key="si">
|
||||||
|
<td
|
||||||
|
v-for="(sf, sj) in resolveSubfields(field, v[field.name])"
|
||||||
|
:key="sj"
|
||||||
|
:class="['dtp-minitd', sf.type === 'currency' || sf.type === 'number' ? 'dtp-minitd--right' : '']"
|
||||||
|
>
|
||||||
|
<CellValue :value="item[sf.name]" :type="sf.type" />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
<!-- mini footer sum -->
|
||||||
|
<tfoot v-if="field.showSubFooter !== false && hasSummableSubs(field, v[field.name])">
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
v-for="(sf, si) in resolveSubfields(field, v[field.name])"
|
||||||
|
:key="si"
|
||||||
|
:class="sf.type === 'currency' || sf.type === 'number' ? 'dtp-minitd--right' : ''"
|
||||||
|
class="dtp-minifoot"
|
||||||
|
>
|
||||||
|
<template v-if="si === 0">
|
||||||
|
<span class="dtp-null">∑</span>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="sf.type === 'currency' || sf.type === 'number'">
|
||||||
|
<CellValue :value="sumSubField(v[field.name], sf.name)" :type="sf.type" />
|
||||||
|
</template>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- ── Normal cell (giữ nguyên logic gốc) ── -->
|
||||||
|
<template v-else>
|
||||||
|
<CellValue
|
||||||
|
v-if="field.type"
|
||||||
|
:value="v[field.name]"
|
||||||
|
:type="field.type"
|
||||||
|
:field="field"
|
||||||
|
/>
|
||||||
|
<span v-else>{{ v[field.name] ?? '' }}</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
|
||||||
|
<!-- ═══════════ TFOOT (summary) ═══════════ -->
|
||||||
|
<tfoot v-if="showSummary">
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
v-for="(field, i) in displayFields"
|
||||||
|
:key="i"
|
||||||
|
:style="field.headerStyle"
|
||||||
|
class="dtp-foot"
|
||||||
|
:class="field.textalign === 'right' ? 'dtp-td--right' : ''"
|
||||||
|
>
|
||||||
|
<span v-if="i === 0" class="dtp-foot-label">∑</span>
|
||||||
|
<CellValue
|
||||||
|
v-else-if="(field.summary === 'sum' || field.type === 'currency') && !field.split"
|
||||||
|
:value="sumColumn(field)"
|
||||||
|
type="currency"
|
||||||
|
/>
|
||||||
|
<span v-else-if="field.summary === 'count'">{{ totalRows }}</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<DatatablePagination
|
||||||
|
v-bind="{ data: data, perPage: perPage }"
|
||||||
|
@changepage="changePage"
|
||||||
|
v-if="showPaging"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
@close="close"
|
||||||
|
@selected="doSelect"
|
||||||
|
@confirm="confirmRemove"
|
||||||
|
v-bind="showmodal"
|
||||||
|
v-if="showmodal"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- ════════════════════════ CELL VALUE COMPONENT ════════════════════════ -->
|
||||||
|
<script>
|
||||||
|
export const CellValue = {
|
||||||
|
name: 'CellValue',
|
||||||
|
props: {
|
||||||
|
value: { default: null },
|
||||||
|
type: { type: String, default: 'string' },
|
||||||
|
field: { type: Object, default: () => ({}) },
|
||||||
|
align: { type: String, default: '' },
|
||||||
|
},
|
||||||
|
setup(props) {
|
||||||
|
const { $formatNumber } = useNuxtApp()
|
||||||
|
function fmtCurrency(v) {
|
||||||
|
if (v === null || v === undefined) return null
|
||||||
|
return Number(v).toLocaleString('vi-VN')
|
||||||
|
}
|
||||||
|
return { fmtCurrency }
|
||||||
|
},
|
||||||
|
template: `
|
||||||
|
<span v-if="value === null || value === undefined" class="dtp-null">—</span>
|
||||||
|
<span v-else-if="type === 'currency'"
|
||||||
|
:class="['dtp-currency', Number(value) < 0 ? 'dtp-currency--neg' : Number(value) === 0 ? 'dtp-currency--zero' : '']"
|
||||||
|
>{{ fmtCurrency(value) }}</span>
|
||||||
|
<span v-else-if="type === 'number'"
|
||||||
|
class="dtp-number">{{ Number(value).toLocaleString('vi-VN') }}</span>
|
||||||
|
<span v-else-if="type === 'date'"
|
||||||
|
class="dtp-date">{{ value }}</span>
|
||||||
|
<span v-else-if="type === 'code'"
|
||||||
|
class="dtp-code">{{ value }}</span>
|
||||||
|
<span v-else-if="type === 'badge' && field.badgeMap"
|
||||||
|
:class="['dtp-badge', field.badgeColorMap?.[value] ?? '']"
|
||||||
|
>{{ field.badgeMap?.[value] ?? value }}</span>
|
||||||
|
<span v-else>{{ value }}</span>
|
||||||
|
`
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, reactive, watch } from "vue/dist/vue.esm-bundler.js"
|
||||||
|
import { defineComponent } from "vue"
|
||||||
|
import { useStore } from "~/stores/index"
|
||||||
|
|
||||||
|
const emit = defineEmits(["edit", "insert", "dataevent", "changedata", "changefilter", "displayDataChange"])
|
||||||
|
const {
|
||||||
|
$calc, $calculate, $calculateData, $copy, $deleterow, $empty,
|
||||||
|
$find, $getEditRights, $formatNumber, $multiSort, $remove, $stripHtml, $unique,
|
||||||
|
} = useNuxtApp()
|
||||||
|
const store = useStore()
|
||||||
|
const props = defineProps({ pagename: String })
|
||||||
|
|
||||||
|
// ── helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
function dynamicComponent(html) {
|
||||||
|
return defineComponent({
|
||||||
|
template: html,
|
||||||
|
props: { row: { type: Object, default: () => ({}) } },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectType(value) {
|
||||||
|
if (typeof value === 'number') return 'number'
|
||||||
|
return 'string'
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveSubfields(field, rows) {
|
||||||
|
if (field.subfields) return field.subfields
|
||||||
|
if (!rows?.length) return []
|
||||||
|
return Object.keys(rows[0]).map(k => ({
|
||||||
|
name: k, label: k,
|
||||||
|
type: typeof rows[0][k] === 'number' ? 'number' : 'string',
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasSummableSubs(field, rows) {
|
||||||
|
if (!rows?.length) return false
|
||||||
|
return resolveSubfields(field, rows).some(sf => sf.type === 'currency' || sf.type === 'number')
|
||||||
|
}
|
||||||
|
|
||||||
|
function sumSubField(rows, name) {
|
||||||
|
return rows.reduce((s, r) => s + (Number(r[name]) || 0), 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function sumColumn(field) {
|
||||||
|
return (data || []).reduce((s, r) => s + (Number(r[field.name]) || 0), 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSorted(field) {
|
||||||
|
return filters?.some(f => f.name === field.name && f.sort)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveSplitTemplate(template, item) {
|
||||||
|
return template.replace(/\{(\w+)\}/g, (_, key) => item[key] ?? '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function evalSplitExpression(expression, item) {
|
||||||
|
try {
|
||||||
|
const expr = expression.replace(/\{(\w+)\}/g, (_, key) => {
|
||||||
|
const v = item[key]
|
||||||
|
return (v === null || v === undefined) ? 'null' : Number(v)
|
||||||
|
})
|
||||||
|
const exprFull = expr.replace(/\b([a-zA-Z_]\w*)\b/g, (match) => {
|
||||||
|
if (!(match in item)) return match
|
||||||
|
const v = item[match]
|
||||||
|
return (v === null || v === undefined) ? 'null' : Number(v)
|
||||||
|
})
|
||||||
|
return Function('"use strict"; return (' + exprFull + ')')()
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── state ────────────────────────────────────────────────────────────────────
|
||||||
|
var timer
|
||||||
|
var showPaging = ref(false)
|
||||||
|
var totalRows = ref(0)
|
||||||
|
var currentPage = 1
|
||||||
|
var displayFields = ref([])
|
||||||
|
var displayData = ref([])
|
||||||
|
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 currentRow
|
||||||
|
var data = $copy(pagedata.data)
|
||||||
|
var showmodal = ref()
|
||||||
|
|
||||||
|
const hasGroupHeaders = computed(() =>
|
||||||
|
displayFields.value.some(f => f.groupHeader)
|
||||||
|
)
|
||||||
|
|
||||||
|
const groupHeaderRow = computed(() => {
|
||||||
|
const fields = displayFields.value
|
||||||
|
if (!fields.length) return []
|
||||||
|
|
||||||
|
const result = []
|
||||||
|
let i = 0
|
||||||
|
while (i < fields.length) {
|
||||||
|
const f = fields[i]
|
||||||
|
if (!f.groupHeader) {
|
||||||
|
result.push({
|
||||||
|
label: f.label,
|
||||||
|
colspan: 1,
|
||||||
|
rowspan: 2,
|
||||||
|
isGroup: false,
|
||||||
|
style: f.headerStyle || '',
|
||||||
|
dropStyle: f.dropStyle || '',
|
||||||
|
field: f,
|
||||||
|
})
|
||||||
|
i++
|
||||||
|
} else {
|
||||||
|
let count = 1
|
||||||
|
while (i + count < fields.length && fields[i + count].groupHeader === f.groupHeader) {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
result.push({
|
||||||
|
label: f.groupHeader,
|
||||||
|
colspan: count,
|
||||||
|
rowspan: 1,
|
||||||
|
isGroup: true,
|
||||||
|
style: f.headerStyle || '',
|
||||||
|
})
|
||||||
|
i += count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── summary ──────────────────────────────────────────────────────────────────
|
||||||
|
const showSummary = computed(() =>
|
||||||
|
pagedata?.showSummary !== false &&
|
||||||
|
displayFields.value.some(f => f.summary || f.type === 'currency')
|
||||||
|
)
|
||||||
|
|
||||||
|
function tdStyle(field, record) {
|
||||||
|
let val = getStyle(field, record)
|
||||||
|
if (field.split === 'stack' || field.split === 'columns') {
|
||||||
|
val = val.replace(/padding:[^;]+;?/g, '') + 'padding:0;vertical-align:top;'
|
||||||
|
}
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── watch ────────────────────────────────────────────────────────────────────
|
||||||
|
watch(() => store[props.pagename], () => { 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 }
|
||||||
|
if (filters.length > 0) doFilter(filters)
|
||||||
|
if (pagedata.update.fields || pagedata.update.data) updateShow()
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateShow = function (full_data) {
|
||||||
|
const allowedFns = { '$getEditRights()': $getEditRights }
|
||||||
|
const arr = pagedata.fields.filter(({ show }) => {
|
||||||
|
if (typeof show === 'boolean') return show
|
||||||
|
if (show === 'true') return true
|
||||||
|
if (show === 'false') return false
|
||||||
|
return allowedFns[show]?.() || false
|
||||||
|
})
|
||||||
|
|
||||||
|
if (full_data === false) {
|
||||||
|
displayData.value = $copy(data)
|
||||||
|
} else {
|
||||||
|
displayData.value = $copy(
|
||||||
|
data.filter((_, idx) =>
|
||||||
|
idx >= (currentPage - 1) * perPage && idx < currentPage * perPage
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
displayData.value.forEach((v, idx) => {
|
||||||
|
v.__stt = (currentPage - 1) * perPage + idx + 1
|
||||||
|
})
|
||||||
|
arr.forEach(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 = (event, row, field) => {
|
||||||
|
const name = typeof event === 'string' ? event : event.name
|
||||||
|
const evdata = 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, evdata)
|
||||||
|
}
|
||||||
|
|
||||||
|
const showField = async (field) => {
|
||||||
|
if (pagedata.contextMenu === false || field.menu === 'no') return
|
||||||
|
currentField = field
|
||||||
|
filterData = $unique(pagedata.data, [field.name])
|
||||||
|
showmodal.value = {
|
||||||
|
vbind: { pagename: props.pagename, field, filters, filterData, width: 100 },
|
||||||
|
component: 'datatable/ContextMenu',
|
||||||
|
title: field.name, width: '650px', height: '500px',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStyle = (field, record) => {
|
||||||
|
let val = (tablesetting.find(v => v.code === 'td-border')?.detail ?? '') + ';'
|
||||||
|
let stop = false
|
||||||
|
if (!Array.isArray(field.bgcolor) && field.bgcolor) {
|
||||||
|
val += ` background-color:${field.bgcolor};`
|
||||||
|
} else if (Array.isArray(field.bgcolor)) {
|
||||||
|
for (const v of field.bgcolor) {
|
||||||
|
if (stop) break
|
||||||
|
const match = v.type === 'search'
|
||||||
|
? record[field.name]?.toLowerCase().includes(v.keyword?.toLowerCase())
|
||||||
|
: $calculate(record, v.tags, v.expression)?.value
|
||||||
|
if (match) { val += ` background-color:${v.color};`; stop = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
stop = false
|
||||||
|
if (!Array.isArray(field.color) && field.color) {
|
||||||
|
val += ` color:${field.color};`
|
||||||
|
} else if (Array.isArray(field.color)) {
|
||||||
|
for (const v of field.color) {
|
||||||
|
if (stop) break
|
||||||
|
if ($calculate(record, v.tags, v.expression)?.value) { val += ` color:${v.color};`; stop = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const fs = tablesetting.find(v => v.code === 'table-font-size')?.detail ?? '12'
|
||||||
|
val += ` font-size:${field.textsize ?? fs}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 = (name, field) => {
|
||||||
|
let val = ''
|
||||||
|
if (name === 'header') {
|
||||||
|
val += `background-color:${tablesetting.find(v => v.code === 'header-background')?.detail};`
|
||||||
|
if (field?.minwidth) val += ` min-width:${field.minwidth}px;`
|
||||||
|
if (field?.maxwidth) val += ` max-width:${field.maxwidth}px;`
|
||||||
|
} else if (name === 'dropdown') {
|
||||||
|
val += `font-size:${tablesetting.find(v => v.code === 'header-font-size')?.detail}px;`
|
||||||
|
val += filters.find(v => v.name === field?.name)
|
||||||
|
? `color:${tablesetting.find(v => v.code === 'header-filter-color')?.detail};`
|
||||||
|
: `color:${tablesetting.find(v => v.code === 'header-font-color')?.detail};`
|
||||||
|
}
|
||||||
|
return val
|
||||||
|
}
|
||||||
|
|
||||||
|
const changePage = (page) => { currentPage = page; updateShow() }
|
||||||
|
|
||||||
|
const showPagination = () => {
|
||||||
|
showPaging.value = pagedata.pagination !== false
|
||||||
|
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 = () => { showmodal.value = undefined }
|
||||||
|
|
||||||
|
const frontendFilter = (newVal) => {
|
||||||
|
newVal = $copy(newVal)
|
||||||
|
let filtered = $copy(pagedata.data)
|
||||||
|
newVal.filter(m => m.select || m.filter).forEach(v => {
|
||||||
|
if (v.select) {
|
||||||
|
filtered = filtered.filter(x =>
|
||||||
|
v.select.findIndex(y => ($empty(y) ? $empty(x[v.name]) : y === x[v.name])) > -1
|
||||||
|
)
|
||||||
|
} else if (v.filter) {
|
||||||
|
filtered = filtered.filter(x => {
|
||||||
|
if ($empty(x[v.name])) return false
|
||||||
|
let text = ''
|
||||||
|
v.filter.forEach((y, k) => {
|
||||||
|
text += `${k > 0 ? (v.filter[k-1].operator === 'and' ? ' &&' : ' ||') : ''}
|
||||||
|
${$formatNumber(x[v.name])} ${y.condition === '=' ? '==' : y.condition === '<>' ? '!==' : y.condition} ${$formatNumber(y.value)}`
|
||||||
|
})
|
||||||
|
return $calc(text)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const sortList = filters.filter(x => x.sort)
|
||||||
|
if (!sortList.length) return filtered
|
||||||
|
const sort = {}, format = {}
|
||||||
|
sortList.forEach(v => { sort[v.name] = v.sort === 'az' ? 'asc' : 'desc'; format[v.name] = v.format })
|
||||||
|
return $multiSort(filtered, sort, format)
|
||||||
|
}
|
||||||
|
|
||||||
|
const doFilter = (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)
|
||||||
|
}
|
||||||
|
pagedata.filters = newVal
|
||||||
|
store.commit(props.pagename, pagedata)
|
||||||
|
emit('changefilter', newVal?.length > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const doSelect = (value) => {
|
||||||
|
showmodal.value = undefined
|
||||||
|
const field = currentField
|
||||||
|
let found = filters.find(v => v.name === field.name)
|
||||||
|
if (found) {
|
||||||
|
if (!found.select) found.select = []
|
||||||
|
const idx = found.select.findIndex(x => x === value)
|
||||||
|
idx >= 0 ? $remove(found.select, idx) : found.select.push(value)
|
||||||
|
if (found.select.length === 0) {
|
||||||
|
const i = filters.findIndex(v => v.name === field.name)
|
||||||
|
if (i >= 0) $remove(filters, i)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
filters.push({ name: field.name, label: field.label, select: [value], format: field.format })
|
||||||
|
}
|
||||||
|
doFilter(filters)
|
||||||
|
updateShow()
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeFilter = (i) => { $remove(filters, i); doFilter(filters); updateShow() }
|
||||||
|
|
||||||
|
const updateData = (newVal) => {
|
||||||
|
if (newVal.filters) filters = $copy(newVal.filters)
|
||||||
|
else if (pagedata.filters) filters = $copy(pagedata.filters)
|
||||||
|
if (newVal.data || newVal.fields || newVal.filters) doFilter(filters)
|
||||||
|
if (newVal.data || newVal.fields || newVal.filters || newVal.tablesetting) updateShow()
|
||||||
|
}
|
||||||
|
|
||||||
|
const doubleClick = (field, v) => { currentField = field; doSelect(v[field.name]) }
|
||||||
|
|
||||||
|
var tableStyle = getSettingStyle('table')
|
||||||
|
setTimeout(() => updateShow(), 200)
|
||||||
|
|
||||||
|
watch(displayData, (val) => {
|
||||||
|
emit('displayDataChange', toRaw(val))
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
/* ── Group headers ─────────────────────────────────────────────── */
|
||||||
|
.dtp-th-group--label {
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color:#ffffff;
|
||||||
|
border-bottom: 2px solid rgba(255,255,255,0.3) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Hover (giữ nguyên) ───────────────────────────────────────── */
|
||||||
|
:deep(.table tbody tr:hover td, .table tbody tr:hover th) {
|
||||||
|
background-color: hsl(0, 0%, 78%);
|
||||||
|
color: rgb(0, 0, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Stack split ──────────────────────────────────────────────── */
|
||||||
|
.dtp-td--stack { padding: 0 !important; vertical-align: top; }
|
||||||
|
.dtp-stack { display: flex; flex-direction: column; }
|
||||||
|
.dtp-stack-row {
|
||||||
|
padding: 4px 8px;
|
||||||
|
min-height: 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
border-bottom: 1px solid #e8ecf0;
|
||||||
|
}
|
||||||
|
.dtp-stack-row:last-child { border-bottom: none; }
|
||||||
|
.dtp-stack-row--empty { color: #bbb; font-style: italic; }
|
||||||
|
.dtp-split-tpl { font-size: 11px; }
|
||||||
|
|
||||||
|
/* ── Columns split (mini-table) ───────────────────────────────── */
|
||||||
|
.dtp-td--columns { padding: 0 !important; vertical-align: top; }
|
||||||
|
.dtp-cols-empty { padding: 6px 10px; color: #bbb; font-style: italic; }
|
||||||
|
.dtp-minitable { width: 100%; border-collapse: collapse; }
|
||||||
|
.dtp-minitable thead th {
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-right: 1px solid #dde2e8;
|
||||||
|
border-bottom: 1px solid #c8cdd3;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.dtp-minitd {
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-right: 1px solid #eef1f4;
|
||||||
|
border-bottom: 1px solid #eef1f4;
|
||||||
|
}
|
||||||
|
.dtp-minitd--right { text-align: right; }
|
||||||
|
.dtp-minitable tbody tr:last-child .dtp-minitd { border-bottom: none; }
|
||||||
|
.dtp-minifoot { font-weight: 700; border-top: 1px solid #c8cdd3 !important; padding: 4px 8px; }
|
||||||
|
|
||||||
|
/* ── Số âm / zero cho currency ────────────────────────────────── */
|
||||||
|
.dtp-currency--neg { color: #ef4444; }
|
||||||
|
.dtp-currency--zero { color: #b0b7c3; }
|
||||||
|
</style>
|
||||||
440
app/components/datatable/DataView.vue
Normal file
440
app/components/datatable/DataView.vue
Normal file
@@ -0,0 +1,440 @@
|
|||||||
|
<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" @displayDataChange="displayDataChange" v-if="pagedata" />
|
||||||
|
<Modal @close="showmodal = undefined" v-bind="showmodal" v-if="showmodal" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import TimeOption from '~/components/datatable/TimeOption'
|
||||||
|
import { ref, watch, onBeforeUnmount, onActivated, onDeactivated } from 'vue'
|
||||||
|
import { useStore } from '~/stores/index'
|
||||||
|
|
||||||
|
const emit = defineEmits(['modalevent', 'dataevent', 'dataUpdated', 'changedata'])
|
||||||
|
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, $exportTableExcel } = 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
function resolveStoreVars(filter) {
|
||||||
|
for (const [key, value] of Object.entries(filter)) {
|
||||||
|
if (value?.toString().indexOf('$') >= 0)
|
||||||
|
filter[key] = store[value.replace('$', '')].id
|
||||||
|
}
|
||||||
|
return filter
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFilteredConn(apiName) {
|
||||||
|
const conn = $findapi(apiName)
|
||||||
|
if (vfilter) {
|
||||||
|
const filter = { ...(conn.params.filter || {}) }
|
||||||
|
for (const [k, v] of Object.entries(vfilter)) filter[k] = v
|
||||||
|
conn.params.filter = resolveStoreVars(filter)
|
||||||
|
}
|
||||||
|
if (vparams) conn.params = $copy(vparams)
|
||||||
|
return conn
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── enrich array fields: lookup FK từ bảng khác, inject vào array items ──────
|
||||||
|
// Cấu hình trong field config:
|
||||||
|
// {
|
||||||
|
// name: "allocation_detail", split: "stack", splitField: "schedule_code",
|
||||||
|
// lookup: {
|
||||||
|
// api: "payment_schedule", localKey: "schedule_id", remoteKey: "id",
|
||||||
|
// values: "id,code,batch_date",
|
||||||
|
// inject: { "schedule_code": "code", "schedule_date": "batch_date" }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
const lookupCache = {}
|
||||||
|
|
||||||
|
function collectLookupTasks(fields) {
|
||||||
|
const tasks = []
|
||||||
|
fields.forEach(field => {
|
||||||
|
if (!field.name) return
|
||||||
|
if (field.lookup && field.split === 'stack') {
|
||||||
|
tasks.push({
|
||||||
|
fieldName: field.name,
|
||||||
|
lookup: {
|
||||||
|
localKey: field.lookup.localKey || field.splitField || 'id',
|
||||||
|
remoteKey: field.lookup.remoteKey || 'id',
|
||||||
|
api: field.lookup.api,
|
||||||
|
values: field.lookup.values,
|
||||||
|
inject: field.lookup.inject || {},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if (field.split === 'columns' && field.subfields?.length) {
|
||||||
|
field.subfields.forEach(sf => {
|
||||||
|
if (!sf.lookup) return
|
||||||
|
tasks.push({
|
||||||
|
fieldName: field.name,
|
||||||
|
subfieldName: sf.name,
|
||||||
|
lookup: {
|
||||||
|
localKey: sf.name,
|
||||||
|
remoteKey: sf.lookup.remoteKey || 'id',
|
||||||
|
api: sf.lookup.api,
|
||||||
|
values: sf.lookup.values,
|
||||||
|
display: sf.lookup.display,
|
||||||
|
inject: sf.lookup.inject || {},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return tasks
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchLookupMaps(tasks, rows) {
|
||||||
|
const groups = {}
|
||||||
|
tasks.forEach(task => {
|
||||||
|
const { api, remoteKey, localKey, values } = task.lookup
|
||||||
|
const cacheKey = `${api}:${remoteKey}`
|
||||||
|
if (!groups[cacheKey]) groups[cacheKey] = { api, remoteKey, values, ids: new Set() }
|
||||||
|
rows.forEach(row => {
|
||||||
|
const arr = row[task.fieldName]
|
||||||
|
if (!arr?.length) return
|
||||||
|
arr.forEach(item => {
|
||||||
|
const id = item[localKey]
|
||||||
|
if (id != null && !lookupCache[cacheKey]?.has(id))
|
||||||
|
groups[cacheKey].ids.add(id)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
await Promise.all(Object.entries(groups).map(async ([cacheKey, group]) => {
|
||||||
|
if (!group.ids.size) return
|
||||||
|
try {
|
||||||
|
const conn = $findapi(group.api)
|
||||||
|
conn.params = {
|
||||||
|
filter: { [`${group.remoteKey}__in`]: [...group.ids] },
|
||||||
|
page: -1,
|
||||||
|
...(group.values ? { values: group.values } : {}),
|
||||||
|
}
|
||||||
|
const rs = await $getapi([conn])
|
||||||
|
const data = rs?.[0]?.data?.rows || rs?.[0]?.data || []
|
||||||
|
if (!lookupCache[cacheKey]) lookupCache[cacheKey] = new Map()
|
||||||
|
const map = lookupCache[cacheKey]
|
||||||
|
;(Array.isArray(data) ? data : []).forEach(r => map.set(r[group.remoteKey], r))
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`[DataView] lookup failed for ${group.api}:`, e)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
return lookupCache
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyLookups(rows, tasks, maps) {
|
||||||
|
if (!tasks.length) return rows
|
||||||
|
return rows.map(row => {
|
||||||
|
const newRow = { ...row }
|
||||||
|
tasks.forEach(task => {
|
||||||
|
const arr = row[task.fieldName]
|
||||||
|
if (!arr?.length) return
|
||||||
|
const { localKey, remoteKey, inject, display } = task.lookup
|
||||||
|
const cacheKey = `${task.lookup.api}:${remoteKey}`
|
||||||
|
const map = maps[cacheKey]
|
||||||
|
if (!map) return
|
||||||
|
newRow[task.fieldName] = arr.map(item => {
|
||||||
|
const remote = map.get(item[localKey])
|
||||||
|
if (!remote) return item
|
||||||
|
const enriched = { ...item }
|
||||||
|
if (inject) Object.entries(inject).forEach(([lf, rf]) => { enriched[lf] = remote[rf] })
|
||||||
|
if (display && task.subfieldName) enriched[`_${task.subfieldName}_display`] = remote[display]
|
||||||
|
return enriched
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return newRow
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enrichArrayFields(rows) {
|
||||||
|
if (!rows?.length) return rows
|
||||||
|
const fields = store[vpagename]?.fields || pagedata.value?.fields || []
|
||||||
|
const tasks = collectLookupTasks(fields)
|
||||||
|
if (!tasks.length) return rows
|
||||||
|
const maps = await fetchLookupMaps(tasks, rows)
|
||||||
|
return applyLookups(rows, tasks, maps)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── hash & polling ────────────────────────────────────────────────────────────
|
||||||
|
const generateDataHash = (data) => {
|
||||||
|
if (!data) return null
|
||||||
|
try {
|
||||||
|
const replacer = (key, value) =>
|
||||||
|
value && typeof value === 'object' && !Array.isArray(value)
|
||||||
|
? Object.keys(value).sort().reduce((s, k) => { s[k] = value[k]; return s }, {})
|
||||||
|
: value
|
||||||
|
const str = JSON.stringify(data, replacer)
|
||||||
|
return str.split('').reduce((a, b) => { a = ((a << 5) - a) + b.charCodeAt(0); return a & a }, 0)
|
||||||
|
} catch (e) { return null }
|
||||||
|
}
|
||||||
|
|
||||||
|
const stopAutoCheck = () => {
|
||||||
|
if (pollingInterval.value) { clearInterval(pollingInterval.value); pollingInterval.value = null }
|
||||||
|
}
|
||||||
|
|
||||||
|
const startAutoCheck = () => {
|
||||||
|
stopAutoCheck()
|
||||||
|
if (realtimeConfig.value.time && realtimeConfig.value.time > 0)
|
||||||
|
pollingInterval.value = setInterval(() => checkDataChanges(), realtimeConfig.value.time * 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkDataChanges = async () => {
|
||||||
|
try {
|
||||||
|
const conn = buildFilteredConn(props.api)
|
||||||
|
delete conn.params.sort
|
||||||
|
delete conn.params.values
|
||||||
|
conn.params.summary = 'aggregate'
|
||||||
|
conn.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' },
|
||||||
|
})
|
||||||
|
const rs = await $getapi([conn])
|
||||||
|
const obj = $find(rs, { name: props.api })
|
||||||
|
const newHash = generateDataHash(obj ? obj.data.rows : {})
|
||||||
|
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 conn = buildFilteredConn(props.api)
|
||||||
|
delete conn.params.summary
|
||||||
|
delete conn.params.distinct_values
|
||||||
|
const rs = await $getapi([conn])
|
||||||
|
const obj = $find(rs, { name: props.api })
|
||||||
|
const rawData = obj ? $copy(obj.data.rows) : []
|
||||||
|
const data = await enrichArrayFields(rawData)
|
||||||
|
updateDataDisplay(data)
|
||||||
|
} catch (error) { console.error('Error loading full data:', error) }
|
||||||
|
}
|
||||||
|
|
||||||
|
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 openImportModal = () => { showmodal.value = $copy(props.importdata) }
|
||||||
|
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())
|
||||||
|
|
||||||
|
// ── timeOption ────────────────────────────────────────────────────────────────
|
||||||
|
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) found.params.filter = resolveStoreVars($copy(props.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
|
||||||
|
resolveStoreVars(filter)
|
||||||
|
if (vfilter) { vfilter = filter; vparams = undefined }
|
||||||
|
else if (vparams) { vparams.filter = filter; vparams.filter_or = undefined }
|
||||||
|
if (!vfilter && !vparams) vfilter = filter
|
||||||
|
getApi()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── edit / insert / events ────────────────────────────────────────────────────
|
||||||
|
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 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 displayDataChange = (args) => {
|
||||||
|
emit('displayDataChange', args);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── getApi ────────────────────────────────────────────────────────────────────
|
||||||
|
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 rawData = props.data ? $copy(props.data) : undefined
|
||||||
|
|
||||||
|
if (!rawData) {
|
||||||
|
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) rawData = $copy(obj.data.rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
pagedata.value = $setpage(vpagename, row, obj)
|
||||||
|
const data = await enrichArrayFields(rawData)
|
||||||
|
const copy = $clone(pagedata.value)
|
||||||
|
copy.data = data
|
||||||
|
copy.update = { data }
|
||||||
|
store.commit(vpagename, copy)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── exportExcel ───────────────────────────────────────────────────────────────
|
||||||
|
const exportExcel = async () => {
|
||||||
|
if (!pagedata.value) return
|
||||||
|
|
||||||
|
const ACTION = ['DebtCheckbox', 'SvgIcon', "name: 'dataevent'", '$emit(', 'modal/']
|
||||||
|
const fields = (pagedata.value.fields || []).filter(f => {
|
||||||
|
if (f.export === 'yes') return true
|
||||||
|
if (!f.show && f.show !== 'true') return false
|
||||||
|
if (f.export === 'no') return false
|
||||||
|
if (f.template && ACTION.some(k => f.template.includes(k))) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
|
||||||
|
const allData = $copy(store[vpagename]?.data || [])
|
||||||
|
if (!allData.length) { $snackbar('Không có dữ liệu để xuất.'); return }
|
||||||
|
|
||||||
|
const filename = $dayjs(new Date()).format('YYYYMMDDHHmmss') + '-data'
|
||||||
|
|
||||||
|
await $exportTableExcel({
|
||||||
|
fields,
|
||||||
|
data: allData,
|
||||||
|
tablesetting: pagedata.value.tablesetting || [],
|
||||||
|
filename,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
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 pb-4" 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="`Go to 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', type: 'primary', 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}` : ''">{{ date ? $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>
|
||||||
145
app/components/datepicker/Datepicker.vue
Normal file
145
app/components/datepicker/Datepicker.vue
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
<template>
|
||||||
|
<div class="control has-icons-left" :id="docid">
|
||||||
|
<div :class="`dropdown w-full ${pos || ''} ${focused ? 'is-active' : ''}`">
|
||||||
|
<div class="dropdown-trigger w-full">
|
||||||
|
<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;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
record: function (newVal) {
|
||||||
|
if (this.record) {
|
||||||
|
this.date = this.record[this.attr]
|
||||||
|
? this.$copy(this.record[this.attr])
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
date: function (newDate) {
|
||||||
|
this.show = newDate && this.$dayjs(newDate).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.$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>
|
||||||
180
app/components/datepicker/PickDay.vue
Normal file
180
app/components/datepicker/PickDay.vue
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
<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 }}
|
||||||
|
</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: { type: [String, Date] }
|
||||||
|
})
|
||||||
|
var dates = []
|
||||||
|
var dateOfWeek = ["T2", "T3", "T4", "T5", "T6", "T7", "CN"]
|
||||||
|
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>
|
||||||
29
app/components/debt/DebtCheckbox.vue
Normal file
29
app/components/debt/DebtCheckbox.vue
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<script setup>
|
||||||
|
const props = defineProps({
|
||||||
|
page: String, // Due/Overdue
|
||||||
|
paymentScheduleId: Number,
|
||||||
|
});
|
||||||
|
const { $store } = useNuxtApp();
|
||||||
|
$store.commit('selectedPaymentSchedulesForEmailInDue', []);
|
||||||
|
$store.commit('selectedPaymentSchedulesForEmailInOverdue', []);
|
||||||
|
const storeProp = `selectedPaymentSchedulesForEmailIn${props.page}`;
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
:checked="$store[storeProp].includes(props.paymentScheduleId)"
|
||||||
|
@change="(e) => {
|
||||||
|
if (e.target.checked) {
|
||||||
|
$store.commit(
|
||||||
|
storeProp,
|
||||||
|
[ ...$store[storeProp], props.paymentScheduleId],
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
$store.commit(
|
||||||
|
storeProp,
|
||||||
|
$store[storeProp].filter(x => x !== props.paymentScheduleId),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
68
app/components/debt/DebtSendEmail.vue
Normal file
68
app/components/debt/DebtSendEmail.vue
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
<script setup>
|
||||||
|
import useSendEmail from '@/components/debt/useSendEmail';
|
||||||
|
import Template1 from '@/lib/email/templates/Template1.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
id: Number,
|
||||||
|
code: String,
|
||||||
|
mode: String // Due/Overdue
|
||||||
|
});
|
||||||
|
const { $download, $generateDocument, $getpath, $insertapi, $store } = useNuxtApp();
|
||||||
|
$store.commit(`selectedPaymentSchedulesForEmailIn${props.mode}`, [props.id]);
|
||||||
|
|
||||||
|
const filter = ref({ id: props.id });
|
||||||
|
const isDownloading = ref({
|
||||||
|
docx: false,
|
||||||
|
pdf: false,
|
||||||
|
});
|
||||||
|
const { contents, send, isSending } = useSendEmail(filter, props.mode === 'Due' ? 13 : 14);
|
||||||
|
|
||||||
|
async function downloadEmail(ext) {
|
||||||
|
isDownloading.value[ext] = true;
|
||||||
|
const doc_code = props.mode === 'Due' ? 'TB_NO_TIEN' : 'TB_NO'
|
||||||
|
const genDoc = await $generateDocument({
|
||||||
|
doc_code,
|
||||||
|
output_filename: `${doc_code}-${props.code}`,
|
||||||
|
payment_schedule_id: props.id,
|
||||||
|
investor_id: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const inserted = await $insertapi('file', {
|
||||||
|
name: genDoc.data[ext === 'docx' ? 'file' : 'pdf'],
|
||||||
|
user: $store.login.id,
|
||||||
|
type: 4,
|
||||||
|
size: 1000,
|
||||||
|
file: genDoc.data[ext === 'docx' ? 'file' : 'pdf']
|
||||||
|
});
|
||||||
|
|
||||||
|
const url = `${$getpath()}download/?name=${inserted.file}&type=contract`;
|
||||||
|
$download(url, inserted.file);
|
||||||
|
isDownloading.value[ext] = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<Template1 v-if="contents" :content="contents[0]" previewMode />
|
||||||
|
<div class="is-flex is-justify-content-center is-gap-1 mt-4">
|
||||||
|
<button
|
||||||
|
class="button is-primary"
|
||||||
|
:class="{ 'is-loading': isSending }"
|
||||||
|
@click="send()"
|
||||||
|
>
|
||||||
|
Gửi thông báo
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="button"
|
||||||
|
:class="{ 'is-loading': isDownloading.docx }"
|
||||||
|
@click="downloadEmail('docx')"
|
||||||
|
>
|
||||||
|
Tải .docx
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="button"
|
||||||
|
:class="{ 'is-loading': isDownloading.pdf }"
|
||||||
|
@click="downloadEmail('pdf')"
|
||||||
|
>
|
||||||
|
Tải .pdf
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
147
app/components/debt/Due.vue
Normal file
147
app/components/debt/Due.vue
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
<script setup>
|
||||||
|
import useSendEmail from '@/components/debt/useSendEmail';
|
||||||
|
import { isEqual } from 'es-toolkit';
|
||||||
|
|
||||||
|
const {
|
||||||
|
$dayjs,
|
||||||
|
$getdata,
|
||||||
|
$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;
|
||||||
|
}
|
||||||
|
|
||||||
|
const paymentSchedules = ref([]);
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const payablesData = await $getdata('bizsetting', undefined, { filter: { classify: 'duepayables' }, sort: 'index' });
|
||||||
|
payables.value = payablesData;
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(activeDateFilter, (val) => {
|
||||||
|
if (!val) {
|
||||||
|
filter.value = defaultFilter;
|
||||||
|
} 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 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 ${$store.selectedPaymentSchedulesForEmailInDue.length} thông báo đến hạn không?`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
watch(filter, () => {
|
||||||
|
key.value += 1;
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
|
watch(key, () => {
|
||||||
|
// reset when DataView re-renders because of filter
|
||||||
|
$store.commit('selectedPaymentSchedulesForEmailInDue', [])
|
||||||
|
})
|
||||||
|
|
||||||
|
function toggleAll() {
|
||||||
|
if ($store.selectedPaymentSchedulesForEmailInDue.length === 0) {
|
||||||
|
$store.commit('selectedPaymentSchedulesForEmailInDue', paymentSchedules.value.map(p => p.id))
|
||||||
|
} else {
|
||||||
|
$store.commit('selectedPaymentSchedulesForEmailInDue', [])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const { contents, send, isSending } = useSendEmail(filter, 13);
|
||||||
|
</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>
|
||||||
|
<div class="buttons" v-if="$store.selectedPaymentSchedulesForEmailInDue !== undefined">
|
||||||
|
<button
|
||||||
|
v-if="$store.selectedPaymentSchedulesForEmailInDue.length > 0"
|
||||||
|
@click="openConfirmModal()"
|
||||||
|
:class="['button', 'is-light', { 'is-loading': isSending }]"
|
||||||
|
>
|
||||||
|
Gửi {{ $store.selectedPaymentSchedulesForEmailInDue.length }} thông báo
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="toggleAll"
|
||||||
|
class="button"
|
||||||
|
:disabled="paymentSchedules.length === 0"
|
||||||
|
>
|
||||||
|
{{ $store.selectedPaymentSchedulesForEmailInDue.length > 0 ? 'Bỏ chọn' : 'Chọn' }} tất cả
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</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: 'id,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',
|
||||||
|
},
|
||||||
|
onDisplayDataChange: (values) => paymentSchedules = values
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
<Modal
|
||||||
|
v-if="showmodal"
|
||||||
|
v-bind="showmodal"
|
||||||
|
@confirm="send"
|
||||||
|
@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>
|
||||||
162
app/components/debt/Overdue.vue
Normal file
162
app/components/debt/Overdue.vue
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
<script setup>
|
||||||
|
import useSendEmail from '@/components/debt/useSendEmail';
|
||||||
|
import { isEqual } from 'es-toolkit';
|
||||||
|
|
||||||
|
const {
|
||||||
|
$dayjs,
|
||||||
|
$getdata,
|
||||||
|
$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;
|
||||||
|
}
|
||||||
|
|
||||||
|
const paymentSchedule = ref([]);
|
||||||
|
const paymentScheduleValues = 'id,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';
|
||||||
|
|
||||||
|
async function setPaymentSheduleData() {
|
||||||
|
const paymentScheduleData = await $getdata(
|
||||||
|
'payment_schedule',
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
|
filter: filter.value,
|
||||||
|
sort: 'to_date',
|
||||||
|
values: paymentScheduleValues,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
paymentSchedule.value = paymentScheduleData;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
setPaymentSheduleData();
|
||||||
|
const payablesData = await $getdata('bizsetting', undefined, { filter: { classify: 'overduepayables' }, sort: 'index' });
|
||||||
|
payables.value = payablesData;
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(activeDateFilter, (val) => {
|
||||||
|
if (!val) {
|
||||||
|
filter.value = defaultFilter;
|
||||||
|
} 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 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 ${$store.selectedPaymentSchedulesForEmailInOverdue.length} thông báo quá hạn không?`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(filter, () => {
|
||||||
|
key.value += 1;
|
||||||
|
setPaymentSheduleData();
|
||||||
|
}, { deep: true })
|
||||||
|
|
||||||
|
watch(key, () => {
|
||||||
|
// reset when DataView re-renders because of filter
|
||||||
|
$store.commit('selectedPaymentSchedulesForEmailInOverdue', [])
|
||||||
|
})
|
||||||
|
|
||||||
|
function toggleAll() {
|
||||||
|
if ($store.selectedPaymentSchedulesForEmailInOverdue.length === 0) {
|
||||||
|
$store.commit('selectedPaymentSchedulesForEmailInOverdue', paymentSchedule.value.map(p => p.id))
|
||||||
|
} else {
|
||||||
|
$store.commit('selectedPaymentSchedulesForEmailInOverdue', [])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const { contents, send, isSending } = useSendEmail(filter, 14);
|
||||||
|
</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>
|
||||||
|
<div class="buttons" v-if="$store.selectedPaymentSchedulesForEmailInOverdue !== undefined">
|
||||||
|
<button
|
||||||
|
v-if="$store.selectedPaymentSchedulesForEmailInOverdue.length > 0"
|
||||||
|
@click="openConfirmModal()"
|
||||||
|
:class="['button', 'is-light', { 'is-loading': isSending }]"
|
||||||
|
>
|
||||||
|
Gửi {{ $store.selectedPaymentSchedulesForEmailInOverdue.length }} thông báo
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click="toggleAll"
|
||||||
|
class="button"
|
||||||
|
>
|
||||||
|
{{ $store.selectedPaymentSchedulesForEmailInOverdue.length > 0 ? 'Bỏ chọn' : 'Chọn' }} tất cả
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</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: paymentScheduleValues,
|
||||||
|
}
|
||||||
|
}"
|
||||||
|
/>
|
||||||
|
<Modal
|
||||||
|
v-if="showmodal"
|
||||||
|
v-bind="showmodal"
|
||||||
|
@confirm="send()"
|
||||||
|
@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>
|
||||||
320
app/components/debt/useSendEmail.js
Normal file
320
app/components/debt/useSendEmail.js
Normal file
@@ -0,0 +1,320 @@
|
|||||||
|
import { render } from '@vue-email/render';
|
||||||
|
import Template1 from '@/lib/email/templates/Template1.vue';
|
||||||
|
import { forEachAsync } from 'es-toolkit';
|
||||||
|
|
||||||
|
export default function useSendEmail(filter, templateId) {
|
||||||
|
const paymentSchedules = ref(null);
|
||||||
|
const contents = ref(null);
|
||||||
|
const isSending = ref(false);
|
||||||
|
const emailTemplate = ref(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
$dayjs,
|
||||||
|
$getdata,
|
||||||
|
$insertapi,
|
||||||
|
$numtoString,
|
||||||
|
$numberToVietnamese,
|
||||||
|
$numberToVietnameseCurrency,
|
||||||
|
$formatDateVN,
|
||||||
|
$getFirstAndLastName,
|
||||||
|
$snackbar,
|
||||||
|
$store,
|
||||||
|
$paymentQR
|
||||||
|
} = useNuxtApp();
|
||||||
|
|
||||||
|
const buildQrHtml = (url) => `
|
||||||
|
<div style="text-align: center; margin-top: 16px">
|
||||||
|
<img
|
||||||
|
src="${url}"
|
||||||
|
alt="VietQR"
|
||||||
|
width="500"
|
||||||
|
style="display: inline-block"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
const formatDateVNText = (input, { withTime = false, withSeconds = false } = {}) => {
|
||||||
|
if (!input) return '';
|
||||||
|
|
||||||
|
const date = new Date(input);
|
||||||
|
if (isNaN(date.getTime())) return '';
|
||||||
|
|
||||||
|
const pad = (n) => String(n).padStart(2, '0');
|
||||||
|
|
||||||
|
const day = pad(date.getDate());
|
||||||
|
const month = pad(date.getMonth() + 1);
|
||||||
|
const year = date.getFullYear();
|
||||||
|
|
||||||
|
let result = `${day} tháng ${month} năm ${year}`;
|
||||||
|
|
||||||
|
if (withTime) {
|
||||||
|
const hours = pad(date.getHours());
|
||||||
|
const minutes = pad(date.getMinutes());
|
||||||
|
result += ` ${hours}:${minutes}`;
|
||||||
|
|
||||||
|
if (withSeconds) {
|
||||||
|
const seconds = pad(date.getSeconds());
|
||||||
|
result += `:${seconds}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
function 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 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
function addDays(days) {
|
||||||
|
const today = new Date();
|
||||||
|
today.setDate(today.getDate() + days);
|
||||||
|
return today;
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
txn_detail__transaction__date,
|
||||||
|
from_date,
|
||||||
|
to_date,
|
||||||
|
cycle,
|
||||||
|
amount,
|
||||||
|
paid_amount,
|
||||||
|
penalty_amount,
|
||||||
|
remain_amount,
|
||||||
|
} = 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\.expiration_date\]/g, $formatDateVN(addDays(3)) || '')
|
||||||
|
.replace(/\[payment_schedule\.amount_remain\]/g, $numtoString(amount) || '')
|
||||||
|
.replace(/\[payment_schedule\.paid_amount\]/g, $numtoString(paid_amount) || '')
|
||||||
|
.replace(/\[payment_schedule\.penalty_amount\]/g, $numtoString(penalty_amount) || '')
|
||||||
|
.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}`}` || '',
|
||||||
|
)
|
||||||
|
.replace(
|
||||||
|
/\[transaction\.date\]/g, formatDateVNText(txn_detail__transaction__date) || '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function quillToEmailHtml(html) {
|
||||||
|
return html
|
||||||
|
// ALIGN (chỉ replace align class, không phá class khác)
|
||||||
|
.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 (xóa class size)
|
||||||
|
.replace(/ql-size-small/g, '')
|
||||||
|
.replace(/ql-size-large/g, '')
|
||||||
|
.replace(/ql-size-huge/g, '')
|
||||||
|
|
||||||
|
// REMOVE EMPTY CLASS
|
||||||
|
.replace(/class=""/g, '')
|
||||||
|
|
||||||
|
// FIX FIGURE MARGIN LEFT + RIGHT
|
||||||
|
.replace(
|
||||||
|
/<figure([^>]*?)style="([^"]*)"/g,
|
||||||
|
(match, before, styles) => {
|
||||||
|
if (!/margin-left\s*:/.test(styles)) {
|
||||||
|
styles += ';margin-left:0';
|
||||||
|
}
|
||||||
|
if (!/margin-right\s*:/.test(styles)) {
|
||||||
|
styles += ';margin-right:0';
|
||||||
|
}
|
||||||
|
return `<figure${before}style="${styles}"`;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildContent(paymentSchedule) {
|
||||||
|
let message = emailTemplate.value.content.content;
|
||||||
|
message = message.replace(
|
||||||
|
/<div[^>]*>\s*<img[^>]*img\.vietqr\.io[^>]*>\s*<\/div>/g,
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
|
||||||
|
const qr = buildQrHtml($paymentQR(buildContentPayment(paymentSchedule), 'TCB'));
|
||||||
|
message = message.trim() + qr;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...emailTemplate.value.content,
|
||||||
|
content: undefined,
|
||||||
|
message: replaceTemplateVars(message, paymentSchedule),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function send() {
|
||||||
|
isSending.value = true;
|
||||||
|
$snackbar(`Đang gửi ${contents.value.length} email thông báo...`);
|
||||||
|
|
||||||
|
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: paymentSchedules.value[i].txn_detail__transaction__customer__email,
|
||||||
|
content: finalEmailHtml,
|
||||||
|
subject: replaceTemplateVars(subject, paymentSchedules.value[i]) || 'Thông báo từ Utopia Villas & Resort',
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response !== null) {
|
||||||
|
await $insertapi('productnote', {
|
||||||
|
ref: paymentSchedules.value[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 ${paymentSchedules.value[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 khách hàng.');
|
||||||
|
isSending.value = false;
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function populatePaymentSchedules(idFilters = {}) {
|
||||||
|
const paymentScheduleData = await $getdata(
|
||||||
|
'payment_schedule',
|
||||||
|
undefined,
|
||||||
|
{
|
||||||
|
filter: {
|
||||||
|
...filter.value,
|
||||||
|
...idFilters,
|
||||||
|
},
|
||||||
|
sort: 'to_date',
|
||||||
|
values: 'id,penalty_paid,penalty_remain,penalty_amount,penalty_reduce,batch_date,amount_remain,paid_amount,remain_amount,code,status,txn_detail,txn_detail__transaction__date,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',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
paymentSchedules.value = paymentScheduleData;
|
||||||
|
contents.value = paymentSchedules.value.map(buildContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function populateEmailTemplate() {
|
||||||
|
const emailTemplateData = await $getdata(
|
||||||
|
'emailtemplate',
|
||||||
|
{ id: templateId },
|
||||||
|
undefined,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
emailTemplate.value = emailTemplateData;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await populateEmailTemplate();
|
||||||
|
populatePaymentSchedules();
|
||||||
|
});
|
||||||
|
|
||||||
|
watch(filter, () => {
|
||||||
|
populatePaymentSchedules();
|
||||||
|
}, { deep: true });
|
||||||
|
|
||||||
|
const storeProp = `selectedPaymentSchedulesForEmailIn${templateId === 13 ? 'Due' : 'Overdue'}`;
|
||||||
|
|
||||||
|
watch(() => $store[storeProp], (val) => {
|
||||||
|
populatePaymentSchedules({ id__in: val });
|
||||||
|
})
|
||||||
|
|
||||||
|
return { contents, send, isSending };
|
||||||
|
}
|
||||||
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-white ml-2" @click="cancel()">Hủy</button>
|
||||||
|
</div>
|
||||||
|
<div class="control" v-if="duration">
|
||||||
|
<CountDown :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>
|
||||||
79
app/components/dialog/CountDown.vue
Normal file
79
app/components/dialog/CountDown.vue
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
<template>
|
||||||
|
<div id="countdown">
|
||||||
|
<span class="fs-15 has-text-grey-dark" style="line-height: 40px">{{ countdown }}</span>
|
||||||
|
<svg>
|
||||||
|
<circle id="track" r="18" cx="20" cy="20"></circle>
|
||||||
|
<circle id="indicator" r="18" cx="20" cy="20"></circle>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<script setup>
|
||||||
|
const props = defineProps({
|
||||||
|
duration: Number
|
||||||
|
});
|
||||||
|
const emit = defineEmits(['close']);
|
||||||
|
const timer = ref();
|
||||||
|
const countdown = ref(props.duration || 10);
|
||||||
|
|
||||||
|
function startCount() {
|
||||||
|
countdown.value -= 1;
|
||||||
|
if (countdown.value === 0) {
|
||||||
|
clearInterval(timer.value);
|
||||||
|
emit('close');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
timer.value = setInterval(startCount, 1000);
|
||||||
|
});
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
clearInterval(timer.value);
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<style scoped>
|
||||||
|
#countdown {
|
||||||
|
position: relative;
|
||||||
|
margin: auto;
|
||||||
|
height: 40px;
|
||||||
|
width: 40px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
:deep(svg) {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
transform: rotateY(-180deg) rotateZ(-90deg);
|
||||||
|
}
|
||||||
|
#indicator {
|
||||||
|
stroke-dasharray: 113px;
|
||||||
|
stroke-dashoffset: 0px;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-width: 2px;
|
||||||
|
stroke: #204853;
|
||||||
|
fill: none;
|
||||||
|
animation-name: countdown;
|
||||||
|
animation-duration: v-bind('duration + "s"');
|
||||||
|
animation-timing-function: linear;
|
||||||
|
animation-iteration-count: infinite;
|
||||||
|
animation-fill-mode: forwards;
|
||||||
|
}
|
||||||
|
#track {
|
||||||
|
stroke-dasharray: 113px;
|
||||||
|
stroke-dashoffset: 0px;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-width: 2px;
|
||||||
|
stroke: hsl(0 100 0% / 0.1);
|
||||||
|
fill: none;
|
||||||
|
}
|
||||||
|
@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>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user