Merge branch 'develop'

This commit is contained in:
Methapon2001 2025-01-07 11:39:09 +07:00
commit 35ae5a918b
353 changed files with 46993 additions and 11837 deletions

325
CHANGELOG.md Normal file
View file

@ -0,0 +1,325 @@
# Changelog
All notable changes to this project will be documented in this file.
## [unreleased]
### 🚀 Features
- *(date-picker)* Readonly and disable handle
- Add abort upload controller as function args
- Add abort upload and upload progress handler
- Add workflow template store
- Add type
- Search and paging workflow template
- Add query to store
- Add api function invoice
- Add status filter
- Ad i19n
- Add field to type
- Add i18n
- Update quotation card
- Add switch component
- Also allow other type (num or enum)
- Add icon support for badge component
- Detect quotation status
- Change view by condition
- Add type payment
- Add view type
- Add dynamic color to quotation form info
- Convert back installment no function
- Filter by installment no
- Send selected installment no to preview
- Payment view
- Add shared product group
- Add params
- Remove flow from store
- Shared product group input
- Add tooltip on hover icon nly button
- Don't allow edit after accepted
- Paycondition
- Also get installment no from preset data
- Hide expire when condition met
- Add util for array management
- Add fetch payment fn
- Update payment stores
- Add attachment manager for payment store
- Add ref type for data
- Abstract function
- Add utils function for array
- Add notifcation store
### 🐛 Bug Fixes
- I18n
- Receipt dialog
- *(05)* Slip file display
- Employee customerBranch display (registerName)
- *(quotation-preview)* Discount not show
- Price calc
- Changing language doesn't work
- *(05)* Layout space and scroll
- *(01)* Search
- Quotation attachment
- *(04)* Payment type option
- *(01)* Search no data
- *(05)* Total padi remain display condition
- Double upload attachment
- *(04)* Search id, flow dialog
- Typo
- Type
- Search no data, data length display
- *(02)* Padding
- *(04)* Summary
- Pagination
- Workflow page
- Wrong type
- Workflow template => flow form data, scroll to last item, sidemenu active
- *(03)* Tel
- Comma input
- Total not update on change tab
- Type wf function
- Status not update on mount
- Assign step id
- Error import
- *(04)* Service type and store
- Main btn & select input component props, etc
- Workflow change status
- *(04)* Service with workflow
- Pagination boundary
- Service work product attributes (workflow)
- Change workflow template on work, undo issue
- Work product installmentNo
- Workname
- Typos
### 🚜 Refactor
- Get customerBranch by id
- Handle telephoneNo
- Handle show moo
- Assign after submit
- Format value before peview
- Add id
- Add query
- Search quotation
- *(utils)* Always append currency .00
- *(form-quotation)* Layout
- Add type Payment
- Create payment file
- Edit btn add
- Move remark
- Add installments type to service
- By dueDate
- *(05)* Upload slip
- Add type installmentNo
- Add input installmentNo
- Handle negative value
- Handle max-width
- Edit name value filName -> nameField
- Upload file card component
- Payment status i18n, danger color
- ReceiptDialog
- Flowdialog
- Use built in query string instead
- I18n
- Workflow dialog & mock data
- Add installmentNo in node
- Add tab status quotation
- Delete title product
- Add installmentNo
- Edit layout input
- Edit status
- Tel i18n
- *(04 flow)* Type and create function
- Flow
- Add i18n
- Show expiration date
- Move function to utile
- Use i18n
- Calculate days expire
- Handle show date expire
- Create BadgeCompoent
- Use BadgeCompoent
- Allow non i118n text to be passed
- Add type QuotationStatus
- Handle QuotationStatus at create
- Create function changeStatus
- Create function accepted
- Create submitAccepted
- Test submit
- Adjust spacing
- Remove param
- Use icon on quotation card
- Prepare for switch view
- Workflow
- Handle rules registerName
- Convert numbers into thai text
- Add agentUserId
- Add type agentUserId
- Handle field only lagelPersonNo , registerName
- Handle type number
- Handle codeHome Not required
- Use variable for color
- Get stats customer
- Handle homeCode
- Set default
- Add payment store skeleton
- Use icon instead of character
- Handle nrcNO not required
- Show namePrefix
- Handle employmentOffice
- NrcNo can is null
- Handle agentUserId
- Handle fiel required form
- Hide add customer at quotation
- Add new column
- Edit column
- Switch nameEN
- Handle data is null
- Edit agent -> agentUserId
- Remove date from installments
- Add selectedAll
- Type paySplit add invoice
- Add table paySplit
- Edit table paySplit
- Id is null
- Invoice is null
- Add page invoice
- File name
- Project structure (1)
- Project structure (2)
- Update status
- Handle btn save
- Project structure (3)
- Util fn
- Handle peview mod
- Delete log
- Extract navigator into store instead
### ⚙️ Miscellaneous Tasks
- Change variable name
- Remove unused
- Format
- Deprecate function
- Add deprecated function
### Refactro
- By installmentNo
- Add i18n
## [0.4.2] - 2024-10-21
### 🚜 Refactor
- Use session storage instead
### ⚙️ Miscellaneous Tasks
- Clean
## [0.4.1] - 2024-10-18
### 🐛 Bug Fixes
- Error undefined
### 🚜 Refactor
- Final price width
## [0.4.0] - 2024-10-18
### 🚀 Features
- Add vat excluded calc
- Store data for preview
- Disable view mode
- Preview route and trigger preview
- Add print button
- *(i18n)* Add text
- *(doc-preview)* Add toolbar
- Add preview footer
- Remark
- Update button and spacing
- Display company name footer
- Add additional info to preview
- Detect edit mode
- Change mode on reset
- Detect if closeable
- Close button
- Add label
- Store it full response as source
### 🐛 Bug Fixes
- *(05)* End of month installments
- Paysplit assign & info display
- Stats not update when change tab with data updated
- Home page menu not working
- *(05)* Node to selected product
- Worker display number, expire date
- *(04)* Pay type
- Background
- Readonly quotation info
- *(05)* Display final price on quotation card
- PricePerUnit calc
- Missing import
- Price scope
- Name
- Wrong calc
- *(05)* Watch paysplit
- Quotation discount
- Remove button
- Type error
- Disabled / readonly field background
- *(05)* Product table
- Display name in table
- Split date
- Hide toggle status
- Reset not actually reset
- Typo
- Delete wrong row
- Warning color
- I18n
- Form info split input
- Readonly editor
### 🚜 Refactor
- Create fetchOption
- Create BankComponents
- Use bank
- Set Option
- Add id
- Add i18n
- Filter bank
- By value
- Add closeTab
- Use app button
- New tab
- Calculate value
- Add remark
- By remark
- Set value default
- Placeholder
- Fetchby id branch
- Edit layout bank
- By bank
- Change button
- Change to secondary button
- WarningClose
- Handle null
- Price data product
- Receipt dialog & type
- Add type
- By value at viewHeader
### ⚙️ Miscellaneous Tasks
- Clean
- Clean log
<!-- generated by git-cliff -->

89
cliff.toml Normal file
View file

@ -0,0 +1,89 @@
# git-cliff ~ default configuration file
# https://git-cliff.org/docs/configuration
#
# Lines starting with "#" are comments.
# Configuration options are organized into tables and keys.
# See documentation for more information on available options.
[changelog]
# changelog header
header = """
# Changelog\n
All notable changes to this project will be documented in this file.\n
"""
# template for the changelog body
# https://keats.github.io/tera/docs/#introduction
body = """
{% if version %}\
## [{{ version | trim_start_matches(pat="version-") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
{% else %}\
## [unreleased]
{% endif %}\
{% for group, commits in commits | group_by(attribute="group") %}
### {{ group | striptags | trim | upper_first }}
{% for commit in commits | unique(attribute="message") %}
- {% if commit.scope %}*({{ commit.scope }})* {% endif %}\
{% if commit.breaking %}💥**breaking**💥 {% endif %}\
{{ commit.message | upper_first }}\
{% endfor %}
{% endfor %}\n
"""
# template for the changelog footer
footer = """
<!-- generated by git-cliff -->
"""
# remove the leading and trailing s
trim = true
# postprocessors
postprocessors = [
# { pattern = '<REPO>', replace = "https://github.com/orhun/git-cliff" }, # replace repository URL
]
[git]
# parse the commits based on https://www.conventionalcommits.org
conventional_commits = true
# filter out the commits that are not conventional
filter_unconventional = true
# process each line of a commit as an individual commit
split_commits = false
# regex for preprocessing the commit messages
commit_preprocessors = [
# Replace issue numbers
#{ pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](<REPO>/issues/${2}))"},
# Check spelling of the commit with https://github.com/crate-ci/typos
# If the spelling is incorrect, it will be automatically fixed.
#{ pattern = '.*', replace_command = 'typos --write-changes -' },
]
# regex for parsing and grouping commits
commit_parsers = [
{ message = "^feat", group = "<!-- 0 -->🚀 Features" },
{ message = "^fix", group = "<!-- 1 -->🐛 Bug Fixes" },
{ message = "^doc", group = "<!-- 3 -->📚 Documentation" },
{ message = "^perf", group = "<!-- 4 -->⚡ Performance" },
{ message = "^refactor", group = "<!-- 2 -->🚜 Refactor" },
{ message = "^style", group = "<!-- 5 -->🎨 Styling" },
{ message = "^test", group = "<!-- 6 -->🧪 Testing" },
{ message = "^chore\\(release\\): prepare for", skip = true },
{ message = "^chore\\(deps.*\\)", skip = true },
{ message = "^chore\\(pr\\)", skip = true },
{ message = "^chore\\(pull\\)", skip = true },
{ message = "^chore|^ci", group = "<!-- 7 -->⚙️ Miscellaneous Tasks" },
{ body = ".*security", group = "<!-- 8 -->🛡️ Security" },
{ message = "^revert", group = "<!-- 9 -->◀️ Revert" },
]
# protect breaking changes from being skipped due to matching a skipping commit_parser
protect_breaking_commits = false
# filter out the commits that are not matched by commit parsers
filter_commits = false
# regex for matching git tags
# tag_pattern = "v[0-9].*"
# regex for skipping tags
# skip_tags = ""
# regex for ignoring tags
# ignore_tags = ""
# sort the tags topologically
topo_order = false
# sort the commits inside sections by oldest/newest order
sort_commits = "oldest"
# limit the number of commits included in the changelog.
limit_commits = 300

View file

@ -11,7 +11,8 @@
"format": "prettier --write \"**/*.{js,ts,vue,scss,html,md,json}\" --ignore-path .gitignore",
"test": "echo \"No test specified\" && exit 0",
"dev": "quasar dev",
"build": "quasar build"
"build": "quasar build",
"changelog:generate": "git-cliff -o CHANGELOG.md"
},
"dependencies": {
"@quasar/extras": "^1.16.12",
@ -30,8 +31,10 @@
"socket.io-client": "^4.7.5",
"tesseract.js": "^5.1.1",
"thai-baht-text": "^2.0.5",
"udsv": "^0.6.0",
"uuid": "^10.0.0",
"vue": "^3.4.38",
"vue-dragscroll": "^4.0.6",
"vue-i18n": "^9.14.0",
"vue-pdf": "^4.3.0",
"vue-router": "^4.4.3"
@ -51,7 +54,8 @@
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-vue": "^9.27.0",
"prettier": "^3.3.3",
"typescript": "^5.5.4"
"typescript": "^5.5.4",
"vue-component-type-helpers": "^2.1.10"
},
"engines": {
"node": "^24 || ^22 || ^20 || ^18",

28
pnpm-lock.yaml generated
View file

@ -56,12 +56,18 @@ importers:
thai-baht-text:
specifier: ^2.0.5
version: 2.0.5
udsv:
specifier: ^0.6.0
version: 0.6.0
uuid:
specifier: ^10.0.0
version: 10.0.0
vue:
specifier: ^3.4.38
version: 3.4.38(typescript@5.5.4)
vue-dragscroll:
specifier: ^4.0.6
version: 4.0.6(typescript@5.5.4)
vue-i18n:
specifier: ^9.14.0
version: 9.14.0(vue@3.4.38(typescript@5.5.4))
@ -117,6 +123,9 @@ importers:
typescript:
specifier: ^5.5.4
version: 5.5.4
vue-component-type-helpers:
specifier: ^2.1.10
version: 2.1.10
packages:
@ -3395,6 +3404,9 @@ packages:
engines: {node: '>=14.17'}
hasBin: true
udsv@0.6.0:
resolution: {integrity: sha512-na+0EoqqpDeNKZ0HVTtgYtFP9aQgsMwPM77UEK7g4OX2C42w+Qw7QZs9t1ocDGLidtcKJnsPy+o5XrBYaZfzCA==}
ufo@1.5.3:
resolution: {integrity: sha512-Y7HYmWaFwPUmkoQCUIAYpKqkOf+SbVj/2fJJZ4RJMCfZp0rTGwRbzQD+HghfnhKOjL9E01okqz+ncJskGYfBNw==}
@ -3507,6 +3519,9 @@ packages:
vm-browserify@1.1.2:
resolution: {integrity: sha512-2ham8XPWTONajOR0ohOKOHXkm3+gaBmGut3SRuu75xLd/RRaY6vqgh8NBYYk7+RW3u5AtzPQZG8F10LHkl0lAQ==}
vue-component-type-helpers@2.1.10:
resolution: {integrity: sha512-lfgdSLQKrUmADiSV6PbBvYgQ33KF3Ztv6gP85MfGaGaSGMTXORVaHT1EHfsqCgzRNBstPKYDmvAV9Do5CmJ07A==}
vue-demi@0.14.10:
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
engines: {node: '>=12'}
@ -3518,6 +3533,9 @@ packages:
'@vue/composition-api':
optional: true
vue-dragscroll@4.0.6:
resolution: {integrity: sha512-zW1k58p41yhmFhmg/JxfesUM4Srl0JfXg7xSINqffVGpHJKvnEHMK4QgF6mUVkPMTgibn976fhPYkomcXPvvFA==}
vue-eslint-parser@9.4.3:
resolution: {integrity: sha512-2rYRLWlIpaiN8xbPiDyXZXRgLGOtWxERV7ND5fFAv5qo1D2N9Fu9MNajBNc6o13lZ+24DAWCkQCvj4klgmcITg==}
engines: {node: ^14.17.0 || >=16.0.0}
@ -7345,6 +7363,8 @@ snapshots:
typescript@5.5.4: {}
udsv@0.6.0: {}
ufo@1.5.3: {}
undici-types@6.19.8: {}
@ -7431,10 +7451,18 @@ snapshots:
vm-browserify@1.1.2: {}
vue-component-type-helpers@2.1.10: {}
vue-demi@0.14.10(vue@3.4.38(typescript@5.5.4)):
dependencies:
vue: 3.4.38(typescript@5.5.4)
vue-dragscroll@4.0.6(typescript@5.5.4):
dependencies:
vue: 3.4.38(typescript@5.5.4)
transitivePeerDependencies:
- typescript
vue-eslint-parser@9.4.3(eslint@8.57.0):
dependencies:
debug: 4.3.4

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Before After
Before After

View file

@ -1,5 +1,51 @@
{
"eng": {
"workerStatus": [
{
"label": "Normal",
"value": "normal"
},
{
"label": "Service Canceled",
"value": "canceled"
},
{
"label": "Resigned",
"value": "resigned"
},
{
"label": "Absconded",
"value": "absconded"
},
{
"label": "Deceased",
"value": "deceased"
},
{
"label": "Repatriated to Home Country",
"value": "repatriated"
},
{
"label": "Did Not Enter Thailand",
"value": "not_entered"
}
],
"workerType": [
{
"label": "Bangkok Bank",
"value": "mou"
},
{
"label": "Nationality Verification Group",
"value": "nvg"
},
{
"label": "Border Pass Group",
"value": "bp"
}
],
"bankBook": [
{
"label": "Bangkok Bank",
@ -53,131 +99,62 @@
}
],
"typeProduct": [
{
"label": "AC",
"value": "AC"
},
{
"label": "DOE",
"value": "DOE"
},
{
"label": "HP",
"value": "HP"
},
{
"label": "IMM",
"value": "IMM"
},
{
"label": "MOUC",
"value": "MOUC"
},
{
"label": "MOUL",
"value": "MOUL"
},
{
"label": "TM",
"value": "TM"
},
{
"label": "VS",
"value": "VS"
},
{
"label": "PPC",
"value": "PPC"
},
{
"label": "AB",
"value": "AB"
},
{
"label": "PPL",
"value": "PPL"
},
{
"label": "PJ",
"value": "PJ"
},
{
"label": "EBS",
"value": "EBS"
},
{
"label": "CI",
"value": "CI"
},
{
"label": "AD",
"value": "AD"
},
{
"label": "WO",
"value": "WO"
},
{
"label": "AE",
"value": "AE"
},
{
"label": "AB1",
"value": "AB1"
},
{
"label": "VS1",
"value": "VS1"
},
{
"label": "PPC1",
"value": "PPC1"
},
{
"label": "PPL1",
"value": "PPL1"
},
{
"label": "AC1",
"value": "AC1"
},
{
"label": "CI1",
"value": "CI1"
},
{
"label": "AD1",
"value": "AD1"
},
{
"label": "MOUL1",
"value": "MOUL1"
},
{
"label": "MOUL3",
"value": "MOUL3"
},
{
"label": "MOUC1",
"value": "MOUC1"
},
{
"label": "HP1",
"value": "HP1"
},
{
"label": "AE3",
"value": "AE3"
},
{
"label": "CAR",
"value": "CAR"
},
{
"label": "AD3",
"value": "AD3"
}
{ "label": "AB", "value": "AB" },
{ "label": "AB1", "value": "AB1" },
{ "label": "AC", "value": "AC" },
{ "label": "AC1", "value": "AC1" },
{ "label": "AC2", "value": "AC2" },
{ "label": "AD", "value": "AD" },
{ "label": "AD1", "value": "AD1" },
{ "label": "AD3", "value": "AD3" },
{ "label": "AE", "value": "AE" },
{ "label": "AE3", "value": "AE3" },
{ "label": "CAR", "value": "CAR" },
{ "label": "CI", "value": "CI" },
{ "label": "CI1", "value": "CI1" },
{ "label": "CI2", "value": "CI2" },
{ "label": "DOE", "value": "DOE" },
{ "label": "EBS", "value": "EBS" },
{ "label": "GI", "value": "GI" },
{ "label": "GO", "value": "GO" },
{ "label": "HP", "value": "HP" },
{ "label": "HP1", "value": "HP1" },
{ "label": "HP2", "value": "HP2" },
{ "label": "IMM", "value": "IMM" },
{ "label": "MOUC", "value": "MOUC" },
{ "label": "MOUC1", "value": "MOUC1" },
{ "label": "MOUC2", "value": "MOUC2" },
{ "label": "MOUL", "value": "MOUL" },
{ "label": "MOUL1", "value": "MOUL1" },
{ "label": "MOUL3", "value": "MOUL3" },
{ "label": "MOUL4", "value": "MOUL4" },
{ "label": "OI", "value": "OI" },
{ "label": "PJ", "value": "PJ" },
{ "label": "PPC", "value": "PPC" },
{ "label": "PPC1", "value": "PPC1" },
{ "label": "PPC2", "value": "PPC2" },
{ "label": "PPL", "value": "PPL" },
{ "label": "PPL1", "value": "PPL1" },
{ "label": "PPL2", "value": "PPL2" },
{ "label": "PREC1", "value": "PREC1" },
{ "label": "PREC2", "value": "PREC2" },
{ "label": "PREC3", "value": "PREC3" },
{ "label": "PREC4", "value": "PREC4" },
{ "label": "PREM1", "value": "PREM1" },
{ "label": "PREM2", "value": "PREM2" },
{ "label": "PREM3", "value": "PREM3" },
{ "label": "PREM4", "value": "PREM4" },
{ "label": "TB", "value": "TB" },
{ "label": "TB1", "value": "TB1" },
{ "label": "TB2", "value": "TB2" },
{ "label": "TM", "value": "TM" },
{ "label": "TM3", "value": "TM3" },
{ "label": "VS", "value": "VS" },
{ "label": "VS1", "value": "VS1" },
{ "label": "VS2", "value": "VS2" },
{ "label": "WO", "value": "WO" },
{ "label": "WP390", "value": "WP390" },
{ "label": "WP44", "value": "WP44" }
],
"prefix": [
@ -513,29 +490,55 @@
}
],
"insurancePlace": [
"checkupResults": [
{
"label": "Pacific Cross",
"value": "pacificcross"
"label": "Normal Results",
"value": "normal_results"
},
{
"label": "Dhipaya Insurance",
"value": "dhipaya"
"label": "Follow-up Treatment",
"value": "follow_up_treatment"
},
{
"label": "Lerdsin Hospital",
"value": "lerdsin"
},
{
"label": "Ratchapiphat Hospital",
"value": "ratchapipat"
},
{
"label": "Charoenkrung Pracharak Hospital",
"value": "krungPracharak"
"label": "Failed Health Checkup",
"value": "failed_checkup"
}
],
"insurancePlace": [
{
"label": "Medical Checkup + Social Insurance",
"value": "social_insurance"
},
{
"label": "Medical Checkup + 990 Insurance",
"value": "insurance_990"
},
{
"label": "Medical Checkup + Government Hospital 3 Months",
"value": "gov_hospital_3m"
},
{
"label": "Medical Checkup + Government Hospital 6 Months",
"value": "gov_hospital_6m"
},
{
"label": "Medical Checkup + Government Hospital 1 Year",
"value": "gov_hospital_1y"
},
{
"label": "Medical Checkup + Government Hospital 1 Year 3 Months",
"value": "gov_hospital_1y_3m"
},
{
"label": "Medical Checkup + Government Hospital 1 Year 6 Months",
"value": "gov_hospital_1y_6m"
},
{
"label": "Medical Checkup + Government Hospital 2 Years",
"value": "gov_hospital_2y"
}
],
"typeReplace": [
{
"label": "Work permit, 3 months (325)",
@ -791,7 +794,11 @@
"label": "Recording By",
"value": "recordBy",
"type": "string"
}
},
{ "label": "Document Check", "value": "documentCheck", "type": "string" },
{ "label": "Duty", "value": "duty", "type": "string" },
{ "label": "Messenger", "value": "messenger", "type": "string" },
{ "label": "Form", "value": "designForm", "type": "string" }
],
"workPropertiesField": [
@ -958,10 +965,95 @@
"label": "Processing Fee",
"value": "processingFee"
}
],
"agenciesType": [
{
"label": "Agency",
"value": "AGE"
},
{
"label": "Department of Employment",
"value": "DOE"
},
{
"label": "Dhipaya Insurance",
"value": "INS"
},
{
"label": "Embassy",
"value": "EMB"
},
{
"label": "Government Agencies",
"value": "GA"
},
{
"label": "Hospital",
"value": "HOS"
},
{
"label": "Immigration Bureau",
"value": "IMB"
},
{
"label": "Immigration Checkpoint",
"value": "IMC"
},
{
"label": "Ministry of Labour",
"value": "MOL"
}
]
},
"tha": {
"workerStatus": [
{
"label": "ปกติ",
"value": "normal"
},
{
"label": "ยกเลิกบริการ",
"value": "canceled"
},
{
"label": "ลาออก",
"value": "resigned"
},
{
"label": "หลบหนี",
"value": "absconded"
},
{
"label": "เสียชีวิต",
"value": "deceased"
},
{
"label": "ส่งกลับประเทศต้นทาง",
"value": "repatriated"
},
{
"label": "ไม่ได้เดินทางเข้าประเทศไทย",
"value": "not_entered"
}
],
"workerType": [
{
"label": "กลุ่ม MOU",
"value": "mou"
},
{
"label": "กลุ่ม พิสูจน์สัญชาติ",
"value": "nvg"
},
{
"label": "กลุ่ม Border pass",
"value": "bp"
}
],
"bankBook": [
{
"label": "ธนาคารกรุงเทพ",
@ -1015,130 +1107,62 @@
}
],
"typeProduct": [
{
"label": "AC",
"value": "AC"
},
{
"label": "DOE",
"value": "DOE"
},
{
"label": "HP",
"value": "HP"
},
{
"label": "IMM",
"value": "IMM"
},
{
"label": "MOUC",
"value": "MOUC"
},
{
"label": "MOUL",
"value": "MOUL"
},
{
"label": "TM",
"value": "TM"
},
{
"label": "VS",
"value": "VS"
},
{
"label": "PPC",
"value": "PPC"
},
{
"label": "AB",
"value": "AB"
},
{
"label": "PPL",
"value": "PPL"
},
{
"label": "PJ",
"value": "PJ"
},
{
"label": "EBS",
"value": "EBS"
},
{
"label": "CI",
"value": "CI"
},
{
"label": "AD",
"value": "AD"
},
{
"label": "WO",
"value": "WO"
},
{
"label": "AE",
"value": "AE"
},
{
"label": "AB1",
"value": "AB1"
},
{
"label": "VS1",
"value": "VS1"
},
{
"label": "PPC1",
"value": "PPC1"
},
{
"label": "PPL1",
"value": "PPL1"
},
{
"label": "AC1",
"value": "AC1"
},
{
"label": "CI1",
"value": "CI1"
},
{
"label": "AD1",
"value": "AD1"
},
{
"label": "MOUL1",
"value": "MOUL1"
},
{
"label": "MOUL3",
"value": "MOUL3"
},
{
"label": "MOUC1",
"value": "MOUC1"
},
{
"label": "HP1",
"value": "HP1"
},
{
"label": "AE3",
"value": "AE3"
},
{
"label": "CAR",
"value": "CAR"
},
{
"label": "AD3",
"value": "AD3"
}
{ "label": "AB", "value": "AB" },
{ "label": "AB1", "value": "AB1" },
{ "label": "AC", "value": "AC" },
{ "label": "AC1", "value": "AC1" },
{ "label": "AC2", "value": "AC2" },
{ "label": "AD", "value": "AD" },
{ "label": "AD1", "value": "AD1" },
{ "label": "AD3", "value": "AD3" },
{ "label": "AE", "value": "AE" },
{ "label": "AE3", "value": "AE3" },
{ "label": "CAR", "value": "CAR" },
{ "label": "CI", "value": "CI" },
{ "label": "CI1", "value": "CI1" },
{ "label": "CI2", "value": "CI2" },
{ "label": "DOE", "value": "DOE" },
{ "label": "EBS", "value": "EBS" },
{ "label": "GI", "value": "GI" },
{ "label": "GO", "value": "GO" },
{ "label": "HP", "value": "HP" },
{ "label": "HP1", "value": "HP1" },
{ "label": "HP2", "value": "HP2" },
{ "label": "IMM", "value": "IMM" },
{ "label": "MOUC", "value": "MOUC" },
{ "label": "MOUC1", "value": "MOUC1" },
{ "label": "MOUC2", "value": "MOUC2" },
{ "label": "MOUL", "value": "MOUL" },
{ "label": "MOUL1", "value": "MOUL1" },
{ "label": "MOUL3", "value": "MOUL3" },
{ "label": "MOUL4", "value": "MOUL4" },
{ "label": "OI", "value": "OI" },
{ "label": "PJ", "value": "PJ" },
{ "label": "PPC", "value": "PPC" },
{ "label": "PPC1", "value": "PPC1" },
{ "label": "PPC2", "value": "PPC2" },
{ "label": "PPL", "value": "PPL" },
{ "label": "PPL1", "value": "PPL1" },
{ "label": "PPL2", "value": "PPL2" },
{ "label": "PREC1", "value": "PREC1" },
{ "label": "PREC2", "value": "PREC2" },
{ "label": "PREC3", "value": "PREC3" },
{ "label": "PREC4", "value": "PREC4" },
{ "label": "PREM1", "value": "PREM1" },
{ "label": "PREM2", "value": "PREM2" },
{ "label": "PREM3", "value": "PREM3" },
{ "label": "PREM4", "value": "PREM4" },
{ "label": "TB", "value": "TB" },
{ "label": "TB1", "value": "TB1" },
{ "label": "TB2", "value": "TB2" },
{ "label": "TM", "value": "TM" },
{ "label": "TM3", "value": "TM3" },
{ "label": "VS", "value": "VS" },
{ "label": "VS1", "value": "VS1" },
{ "label": "VS2", "value": "VS2" },
{ "label": "WO", "value": "WO" },
{ "label": "WP390", "value": "WP390" },
{ "label": "WP44", "value": "WP44" }
],
"prefix": [
@ -1474,26 +1498,53 @@
}
],
"checkupResults": [
{
"label": "ผลตรวจปกติ",
"value": "normal_results"
},
{
"label": "ติดตามการรักษา",
"value": "follow_up_treatment"
},
{
"label": "ไม่ผ่านการตรวจสุขภาพ",
"value": "failed_checkup"
}
],
"insurancePlace": [
{
"label": "แปซิฟิคครอส",
"value": "pacificcross"
"label": "ตรวจโรค + ประกันสังคม",
"value": "social_insurance"
},
{
"label": "ทิพยประกันภัย",
"value": "dhipaya"
"label": "ตรวจโรค + ประกัน 990",
"value": "insurance_990"
},
{
"label": "รพ.เลิดสิน 4.รพ.ตากสิน",
"value": "lerdsin"
"label": "ตรวจโรค + ประกัน รพ.รัฐ 3 เดือน",
"value": "gov_hospital_3m"
},
{
"label": "รพ.ราชพิพัฒน์",
"value": "ratchapipat"
"label": "ตรวจโรค + ประกัน รพ.รัฐ 6 เดือน",
"value": "gov_hospital_6m"
},
{
"label": "รพ.เจริญกรุงประชารักษ์",
"value": "krungPracharak"
"label": "ตรวจโรค + ประกัน รพ.รัฐ 1 ปี",
"value": "gov_hospital_1y"
},
{
"label": "ตรวจโรค + ประกัน รพ.รัฐ 1 ปี 3 เดือน",
"value": "gov_hospital_1y_3m"
},
{
"label": "ตรวจโรค + ประกัน รพ.รัฐ 1 ปี 6 เดือน",
"value": "gov_hospital_1y_6m"
},
{
"label": "ตรวจโรค + ประกัน รพ.รัฐ 2 ปี",
"value": "gov_hospital_2y"
}
],
@ -1752,7 +1803,11 @@
"label": "ลงชื่อผู้บันทึกข้อมูล",
"value": "recordBy",
"type": "string"
}
},
{ "label": "ตรวจสอบเอกสาร", "value": "documentCheck", "type": "string" },
{ "label": "อากร", "value": "duty", "type": "string" },
{ "label": "พนักงานส่งเอกสาร", "value": "messenger", "type": "string" },
{ "label": "ออกแบบฟอร์ม", "value": "designForm", "type": "string" }
],
"workPropertiesField": [
@ -1919,6 +1974,45 @@
"label": "ค่าดำเนินงาน",
"value": "processingFee"
}
],
"agenciesType": [
{
"label": "เอเจนซี่ / หน่วยงาน",
"value": "AGE"
},
{
"label": "จัดหางานพื้นที่",
"value": "DOE"
},
{
"label": "ทิพยประกันภัย",
"value": "INS"
},
{
"label": "สถานทูต",
"value": "EMB"
},
{
"label": "หน่วยงานราชการ",
"value": "GA"
},
{
"label": "โรงพยาบาล",
"value": "HOS"
},
{
"label": "สำนักงานตรวจคนเข้าเมือง",
"value": "IMB"
},
{
"label": "ด่านตรวจคนเข้าเมือง",
"value": "IMC"
},
{
"label": "กรมแรงงาน",
"value": "MOL"
}
]
}
}

View file

@ -35,10 +35,11 @@ export default configure((ctx) => {
devServer: {
host: '0.0.0.0',
open: false,
port: 5173,
},
framework: {
config: {},
plugins: ['Dark', 'Dialog', 'Notify'],
plugins: ['Dark', 'Dialog', 'Notify', 'Loading'],
iconSet: 'mdi-v7',
cssAddon: true,
},

View file

@ -3,9 +3,11 @@ import VueDatePicker from '@vuepic/vue-datepicker';
import '@vuepic/vue-datepicker/dist/main.css';
import GlobalDialog from 'components/GlobalDialog.vue';
import GlobalLoading from 'components/GlobalLoading.vue';
import VueDragscroll from 'vue-dragscroll';
export default boot(({ app }) => {
app.component('global-dialog', GlobalDialog);
app.component('global-loading', GlobalLoading);
app.component('VueDatePicker', VueDatePicker);
app.use(VueDragscroll);
});

View file

@ -21,13 +21,16 @@ declare module 'vue-i18n' {
}
/* eslint-enable @typescript-eslint/no-empty-interface */
export default boot(({ app }) => {
const i18n = createI18n({
locale: 'tha',
legacy: false,
messages,
});
export const i18n = createI18n({
locale: 'tha',
legacy: false,
messages: {
'en-US': {},
...messages,
},
});
export default boot(({ app }) => {
// Set i18n instance on app
app.use(i18n);
});

View file

@ -59,7 +59,10 @@ defineProps<{
</q-img>
</div>
<div class="branch-card__name flex justify-center q-ml-sm">
<b>{{ data.branchLabelName }}</b>
<b class="ellipsis-2-lines">
{{ data.branchLabelName }}
<q-tooltip>{{ data.branchLabelName }}</q-tooltip>
</b>
<small class="branch-card__code" v-if="data.branchLabelCode">
{{ data.branchLabelCode }}
</small>
@ -98,11 +101,19 @@ defineProps<{
<slot name="data"></slot>
<template v-if="!$slots.data">
<div
v-for="key in fieldSelected || [
'branchLabelAddress',
'branchLabelTel',
'branchLabelType',
]"
v-for="key in (
fieldSelected || [
'branchLabelAddress',
'branchLabelTel',
'branchLabelType',
]
).sort((lhs, rhs) => {
let order = Object.keys(data);
return (
order.findIndex((i) => i === lhs) -
order.findIndex((i) => i === rhs)
);
})"
:key="key"
class="branch-card__data"
>

View file

@ -173,9 +173,10 @@ watch(
<div
class="bordered q-mr-sm rounded"
:class="{ 'cursor-pointer': !readonly }"
:class="{ 'pointer-none': readonly }"
>
<ImageHover
:readonly="readonly"
:img="book.bankUrl"
@view="() => $emit('viewQr', i)"
@edit="
@ -347,4 +348,8 @@ watch(
</div>
</div>
</template>
<style scoped lang="scss"></style>
<style scoped lang="scss">
.pointer-none {
pointer-events: none;
}
</style>

View file

@ -1,6 +1,7 @@
<script setup lang="ts">
import { isRoleInclude } from 'src/stores/utils';
import DatePicker from '../shared/DatePicker.vue';
import { disabledAfterToday } from 'src/utils/datetime';
const code = defineModel<string>('code');
const branchCount = defineModel<number>('branchCount', { default: 0 });
@ -12,8 +13,8 @@ const nameEN = defineModel<string>('nameEn');
const typeBranch = defineModel<string>('typeBranch');
const virtual = defineModel<boolean>('virtual');
const permitExpireDate = defineModel<Date>('permitExpireDate');
const permitIssueDate = defineModel<Date>('permitIssueDate');
const permitExpireDate = defineModel<Date | null>('permitExpireDate');
const permitIssueDate = defineModel<Date | null>('permitIssueDate');
const permitNo = defineModel<string>('permitNo');
defineProps<{
@ -123,11 +124,17 @@ function formatCode(input: string | undefined, type: 'code' | 'number') {
:label="
typeBranch === 'headOffice'
? $t('branch.form.headofficeName')
: $t('branch.form.branchName')
: virtual === true
? $t('branch.form.servicePointName')
: $t('branch.form.branchName')
"
v-model="name"
:rules="[(val) => val && val.length > 0]"
:error-message="$t('form.error.required')"
:rules="[
(val) => !!val || $t('form.error.required'),
(val) =>
/^[A-Za-z0-9ก-๙\s&.,'-]+$/.test(val) ||
$t('form.error.branchNameField'),
]"
for="input-name"
/>
<q-input
@ -140,12 +147,15 @@ function formatCode(input: string | undefined, type: 'code' | 'number') {
:label="
typeBranch === 'headOffice'
? $t('branch.form.headofficeNameEN')
: $t('branch.form.branchNameEN')
: virtual === true
? $t('branch.form.servicePointNameEN')
: $t('branch.form.branchNameEN')
"
:rules="[
(val) => !!val || $t('form.error.required'),
(val) =>
/^[A-Za-z0-9\s.,]+$/.test(val) || $t('form.error.letterOnly'),
/^[A-Za-z0-9\s&.,'-]+$/.test(val) ||
$t('form.error.branchNameENField'),
]"
for="input-name-en"
/>
@ -198,7 +208,7 @@ function formatCode(input: string | undefined, type: 'code' | 'number') {
v-model="permitNo"
:rules="[(val) => val && val.length > 0]"
:error-message="$t('form.error.required')"
for="input-name"
for="input-license-number"
/>
<DatePicker
@ -216,6 +226,12 @@ function formatCode(input: string | undefined, type: 'code' | 'number') {
:readonly="readonly"
:label="$t('general.expirationDate')"
v-model="permitExpireDate"
:disabled-dates="
(date: Date) =>
date.getTime() <
((permitIssueDate && new Date(permitIssueDate).getTime()) ||
Date.now())
"
clearable
/>
</div>

View file

@ -9,6 +9,8 @@ import { Icon } from '@iconify/vue';
import { QSelect } from 'quasar';
import DatePicker from '../shared/DatePicker.vue';
import SelectOffice from 'components/shared/select-muliple/SelectOffice.vue';
const { t } = useI18n();
const userStore = useUserStore();
const optionStore = useOptionStore();
@ -18,7 +20,7 @@ const userType = defineModel<string>('userType');
const registrationNo = defineModel<string | null>('registrationNo');
const startDate = defineModel<Date | null | string>('startDate');
const retireDate = defineModel<Date | null | string>('retireDate');
const responsibleArea = defineModel<string | null | undefined>(
const responsibleArea = defineModel<string[] | null | undefined>(
'responsibleArea',
);
const discountCondition = defineModel<string | null | undefined>(
@ -175,44 +177,20 @@ watch(
:readonly="readonly"
:label="$t('personnel.form.retireDate')"
v-model="retireDate"
:disabled-dates="
(date: Date) =>
date.getTime() <
((startDate && new Date(startDate).getTime()) || Date.now())
"
clearable
/>
<q-select
<SelectOffice
v-model:value="responsibleArea"
v-if="userType === 'MESSENGER'"
outlined
clearable
use-input
fill-input
emit-value
map-options
hide-selected
hide-bottom-space
class="col-12"
input-debounce="0"
option-label="label"
option-value="value"
id="input-responsible-area"
:dense="dense"
:readonly="readonly"
:hide-dropdown-icon="readonly"
:label="$t('personnel.form.responsibleArea')"
:options="responsibleAreaOptions"
@filter="responsibleAreaFilter"
:model-value="readonly ? responsibleArea || '-' : responsibleArea"
@update:model-value="
(v) => (typeof v === 'string' ? (responsibleArea = v) : '')
"
@clear="responsibleArea = ''"
>
<template v-slot:no-option>
<q-item>
<q-item-section class="text-grey">
{{ $t('general.noData') }}
</q-item-section>
</q-item>
</template>
</q-select>
/>
</div>
<div
v-if="userType === 'DELEGATE'"

View file

@ -1,9 +1,10 @@
<script setup lang="ts">
import useUserStore from 'stores/user';
import { selectFilterOptionRefMod } from 'stores/utils';
import { onMounted, ref, watch } from 'vue';
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { isRoleInclude } from 'src/stores/utils';
import SelectBranch from '../shared/select/SelectBranch.vue';
const userStore = useUserStore();
const { t } = useI18n();
@ -15,7 +16,7 @@ const userRole = defineModel<string>('userRole');
const username = defineModel<string | null | undefined>('username');
const userCode = defineModel<string>('userCode');
const props = defineProps<{
defineProps<{
title?: string;
dense?: boolean;
outlined?: boolean;
@ -24,35 +25,6 @@ const props = defineProps<{
usernameReadonly?: boolean;
}>();
async function selectHq(id: string) {
if (!id) return;
brId.value = '';
userStore.userOption.brOpts = [];
await userStore.fetchBrOption(id);
if (userStore.userOption.brOpts.length === 1) {
brId.value = userStore.userOption.brOpts[0].value;
}
brFilter = selectFilterOptionRefMod(
ref(userStore.userOption.brOpts),
brOptions,
'label',
);
}
const hqOptions = ref<Record<string, unknown>[]>([]);
const hqFilter = selectFilterOptionRefMod(
ref(userStore.userOption.hqOpts),
hqOptions,
'label',
);
const brOptions = ref<Record<string, unknown>[]>([]);
let brFilter = selectFilterOptionRefMod(
ref(userStore.userOption.brOpts),
brOptions,
'label',
);
const userTypeOptions = ref<Record<string, unknown>[]>([]);
const userTypeFilter = selectFilterOptionRefMod(
ref(
@ -71,35 +43,6 @@ const roleFilter = selectFilterOptionRefMod(
roleOptions,
'label',
);
onMounted(async () => {
if (userStore.userOption.hqOpts[0].value && !props.readonly) {
await userStore.fetchBrOption(userStore.userOption.hqOpts[0].value);
if (userStore.userOption.brOpts.length === 1) {
brId.value = userStore.userOption.brOpts[0].value;
}
brFilter = selectFilterOptionRefMod(
ref(userStore.userOption.brOpts),
brOptions,
'label',
);
}
});
watch(
() => hqId.value,
async (v) => {
if (v) {
userStore.userOption.brOpts = [];
await userStore.fetchBrOption(v);
brFilter = selectFilterOptionRefMod(
ref(userStore.userOption.brOpts),
brOptions,
'label',
);
}
},
);
</script>
<template>
<div class="row col-12">
@ -116,81 +59,35 @@ watch(
</div>
<div class="col-12 row q-col-gutter-sm">
<q-select
outlined
clearable
use-input
fill-input
emit-value
map-options
hide-selected
hide-bottom-space
:disable="isRoleInclude(['branch_manager']) && !readonly"
v-model="hqId"
for="select-hq-id"
input-debounce="0"
option-label="label"
option-value="value"
<SelectBranch
id="select-hq-id"
class="col-md-2 col-12"
:dense="dense"
:readonly="readonly"
:hide-dropdown-icon="readonly"
:readonly
:disabled="isRoleInclude(['branch_manager']) && !readonly"
:params="{
filter: 'head',
}"
:label="$t('branch.form.code')"
:options="hqOptions"
:rules="[
(val: string) =>
!!val ||
$t('form.error.selectField', { field: $t('branch.form.code') }),
]"
@update:model-value="(val: string) => selectHq(val)"
@filter="hqFilter"
@clear="
() => {
(hqId = ''), (brId = '');
}
"
>
<template v-slot:no-option>
<q-item>
<q-item-section class="text-grey">
{{ $t('general.noData') }}
</q-item-section>
</q-item>
</template>
</q-select>
<q-select
outlined
code-only
clearable
use-input
fill-input
emit-value
map-options
hide-selected
hide-bottom-space
for="select-br-id"
input-debounce="0"
option-label="label"
option-value="value"
v-model:value="hqId"
@update:value="() => (brId = '')"
required
/>
<SelectBranch
id="select-br-id"
class="col-md-2 col-12"
:disable="isRoleInclude(['branch_manager']) && !readonly"
:dense="dense"
:readonly="readonly"
:hide-dropdown-icon="readonly"
:key="hqId ?? undefined"
:readonly
:disabled="(isRoleInclude(['branch_manager']) && !readonly) || !hqId"
:params="{
headOfficeId: hqId ?? undefined,
}"
:label="$t('branch.form.codeBranch')"
:options="brOptions"
@filter="brFilter"
:model-value="readonly ? brId || '-' : brId"
@update:model-value="(v) => (typeof v === 'string' ? (brId = v) : '')"
@clear="brId = ''"
>
<template v-slot:no-option>
<q-item>
<q-item-section class="text-grey">
{{ $t('general.noData') }}
</q-item-section>
</q-item>
</template>
</q-select>
code-only
clearable
v-model:value="brId"
/>
<q-input
for="input-username"
:dense="dense"
@ -226,6 +123,7 @@ watch(
option-value="value"
option-label="label"
for="select-user-type"
autocomplete="off"
:dense="dense"
:readonly="readonly"
:hide-dropdown-icon="readonly"
@ -259,6 +157,7 @@ watch(
hide-bottom-space
class="col"
input-debounce="0"
autocomplete="off"
option-label="label"
option-value="value"
for="select-user-role"

View file

@ -55,7 +55,7 @@ let nationalityFilter: (
function matPreFixName() {
if (gender.value === 'male') prefixName.value = 'mr';
else prefixName.value = 'mrs';
if (gender.value === 'female') prefixName.value = 'mrs';
}
onMounted(() => {
@ -162,6 +162,7 @@ watch(
option-label="label"
option-value="value"
hide-dropdown-icon
autocomplete="off"
class="col-md-1 col-6"
:dense="dense"
:readonly="readonly"
@ -261,7 +262,7 @@ watch(
:rules="[
(val: string) => !!val || $t('form.error.required'),
(val: string) =>
/^[A-Za-z]+$/.test(val) || $t('form.error.letterOnly'),
/^[A-Za-z\s]+$/.test(val) || $t('form.error.letterOnly'),
]"
/>
<q-input
@ -290,7 +291,7 @@ watch(
:rules="[
(val: string) => !!val || $t('form.error.required'),
(val: string) =>
/^[A-Za-z]+$/.test(val) || $t('form.error.letterOnly'),
/^[A-Za-z\s]+$/.test(val) || $t('form.error.letterOnly'),
]"
/>
</div>
@ -364,6 +365,7 @@ watch(
input-debounce="0"
option-label="label"
option-value="value"
autocomplete="off"
class="col-md-2 col-6"
:dense="dense"
:readonly="readonly"
@ -422,7 +424,6 @@ watch(
:id="`${prefixId}-input-citizen-issue`"
:readonly="readonly"
:label="$t('personnel.form.citizenIssue')"
:disabled-dates="disabledAfterToday"
:rules="[
(val: string) =>
!!val ||
@ -430,6 +431,17 @@ watch(
field: $t('personnel.form.citizenIssue'),
}),
]"
@update:model-value="
(v) => {
if (!v) return;
if (!citizenExpire) return;
if (new Date(v).getTime() >= new Date(citizenExpire).getTime()) {
const newValue = new Date(v);
newValue.setDate(newValue.getDate() + 1);
citizenExpire = newValue;
}
}
"
/>
<DatePicker
@ -439,6 +451,11 @@ watch(
:id="`${prefixId}-input-citizen-expire`"
:readonly="readonly"
:label="$t('personnel.form.citizenExpire')"
:disabled-dates="
(date: Date) =>
date.getTime() <
((citizenIssue && new Date(citizenIssue).getTime()) || Date.now())
"
/>
<q-select
@ -450,6 +467,7 @@ watch(
emit-value
map-options
hide-selected
autocomplete="off"
hide-bottom-space
input-debounce="0"
option-label="label"
@ -483,6 +501,7 @@ watch(
map-options
hide-selected
hide-bottom-space
autocomplete="off"
input-debounce="0"
option-label="label"
option-value="value"

View file

@ -1,5 +1,6 @@
<script lang="ts" setup>
defineProps<{
prefixId?: string;
inactive?: boolean;
color?: 'none' | 'pers' | 'corp';
data: {
@ -9,6 +10,7 @@ defineProps<{
telephone: string;
businessTypePure: string;
totalEmployee: number;
code: string;
};
metadata?: unknown;
badgeField?: string[];
@ -47,11 +49,12 @@ defineProps<{
</div>
<div class="branch-card__name">
<b>{{ data.branchName }}</b>
<small class="branch-card__code">{{ data.branchName }}</small>
<small class="branch-card__code">{{ data.code }}</small>
</div>
<div class="branch-card__action">
<q-btn
:id="`${prefixId}-btn-view-detail`"
icon="mdi-eye-outline"
size="sm"
dense
@ -73,18 +76,25 @@ defineProps<{
"
/>
<div
v-for="key in fieldSelected?.sort() || [
'customerBranchFormTab',
'branchName',
v-for="key in fieldSelected || [
'address',
'telephone',
'businessTypePure',
'totalEmployee',
]"
:key="key"
class="branch-card__data"
>
<div>{{ $t(key) }}</div>
<div>{{ data[key as keyof typeof data] }}</div>
<div>
{{
fieldSelected
? $t(key)
: key !== 'address' && key !== 'telephone'
? $t(`customer.table.${key}`)
: $t(`general.${key}`)
}}
</div>
<div>{{ data[key as keyof typeof data] || '-' }}</div>
</div>
</div>
</template>
@ -172,7 +182,7 @@ defineProps<{
}
&.branch-card__pers {
--_branch-card-bg: var(--pink-6-hsl);
--_branch-card-bg: var(--teal-10-hsl);
}
&.branch-card__corp {

View file

@ -1,31 +1,42 @@
<script lang="ts" setup>
import { calculateDaysUntilExpire } from 'stores/utils';
defineProps<{
expirationDate: Date;
showAllDay?: boolean;
}>();
function calculateDaysUntilExpire(expireDate: Date): number {
const today = new Date();
const expire = new Date(expireDate);
const diffInTime = expire.getTime() - today.getTime();
const diffInDays = Math.ceil(diffInTime / (1000 * 60 * 60 * 24));
return diffInDays;
}
</script>
<template>
<template v-if="calculateDaysUntilExpire(expirationDate) <= 90">
<template
v-if="
calculateDaysUntilExpire(expirationDate) <= 90 ||
(expirationDate !== undefined && !!showAllDay)
"
>
<q-badge
:color="calculateDaysUntilExpire(expirationDate) > 0 ? 'orange' : 'red'"
:color="
calculateDaysUntilExpire(expirationDate) > 90
? 'green'
: calculateDaysUntilExpire(expirationDate) <= 90 &&
calculateDaysUntilExpire(expirationDate) > 0
? 'orange'
: 'red'
"
class="text-weight-bold"
>
{{
calculateDaysUntilExpire(expirationDate) > 0
? 'จะครบกำหนดในอีก'
: calculateDaysUntilExpire(expirationDate) === 0
? 'ครบกำหนด'
: 'เลยกำหนด'
$t(
`general.${
calculateDaysUntilExpire(expirationDate) > 0
? 'beDue'
: calculateDaysUntilExpire(expirationDate) === 0
? 'due'
: 'overDue'
}`,
)
}}
{{
calculateDaysUntilExpire(expirationDate) > 0
? calculateDaysUntilExpire(expirationDate)
@ -34,10 +45,11 @@ function calculateDaysUntilExpire(expireDate: Date): number {
: calculateDaysUntilExpire(expirationDate) * -1
}}
<template v-if="calculateDaysUntilExpire(expirationDate) !== 0">
{{ $t('general.day') }}
</template>
</q-badge>
</template>
<template v-else>-</template>
</template>
<style scoped></style>

View file

@ -9,6 +9,8 @@ import { EmployeeCheckupCreate } from 'stores/employee/types';
import { selectFilterOptionRefMod } from 'stores/utils';
import { QSelect } from 'quasar';
import DatePicker from '../shared/DatePicker.vue';
import SelectInput from 'components/shared/SelectInput.vue';
import {
AddButton,
EditButton,
@ -31,6 +33,11 @@ const addrOptions = reactive<{
const currentIndex = defineModel<number>('currentIndex');
const employeeCheckup = defineModel<EmployeeCheckupCreate[]>('employeeCheckup');
const checkupResultsOption = defineModel<{ label: string; value: string }[]>(
'checkupResultsOption',
{ required: true },
);
const checkupTypeOption = defineModel<{ label: string; value: string }[]>(
'checkupTypeOption',
{ required: true },
@ -82,28 +89,6 @@ async function fetchProvince() {
);
}
function addCheckup() {
// const canAdd = checkTabBeforeAdd(employeeCheckup.value || []);
const canAdd = true;
if (canAdd) {
employeeCheckup.value?.push({
coverageExpireDate: null,
coverageStartDate: null,
insuranceCompany: '',
medicalBenefitScheme: '',
remark: '',
hospitalName: '',
provinceId: '',
checkupResult: '',
checkupType: '',
});
if (employeeCheckup.value) {
tab.value = `tab${employeeCheckup.value.length - 1}`;
currentIndex.value = employeeCheckup.value.length - 1;
}
}
}
function removeData(index: number) {
if (!employeeCheckup.value) return;
if (employeeCheckup.value.length === 1) return;
@ -126,6 +111,13 @@ let provinceFilter: (
update: (callbackFn: () => void, afterFn?: (ref: QSelect) => void) => void,
) => void;
const checkupResultsOptions = ref<Record<string, unknown>[]>([]);
const checkupResultsFilter = selectFilterOptionRefMod(
checkupResultsOption,
checkupResultsOptions,
'label',
);
const checkupTypeOptions = ref<Record<string, unknown>[]>([]);
const checkupTypeFilter = selectFilterOptionRefMod(
checkupTypeOption,
@ -149,28 +141,7 @@ const insuranceCompanyFilter = selectFilterOptionRefMod(
</script>
<template>
<div class="row col-12">
<div class="col-12 q-pb-sm text-weight-bold text-body1 row items-center">
<q-icon
flat
size="xs"
class="q-pa-sm rounded q-mr-sm"
color="info"
name="mdi-hospital-box-outline"
style="background-color: var(--surface-3)"
/>
{{ $t(`customerEmployee.formHealthCheck.title`) }}
<AddButton
v-if="currentIndex === -1 && !hideAction"
id="btn-add-bank"
icon-only
class="q-ml-sm"
type="button"
@click="addCheckup"
:disabled="!(currentIndex === -1)"
/>
</div>
<div class="row">
<div
v-for="(checkup, index) in employeeCheckup"
v-bind:key="index"
@ -222,15 +193,6 @@ const insuranceCompanyFilter = selectFilterOptionRefMod(
</div>
</span>
<q-input
:dense="dense"
outlined
:readonly="readonly || checkup.statusSave"
hide-bottom-space
class="col-5"
:label="$t('customerEmployee.formHealthCheck.result')"
v-model="checkup.checkupResult"
/>
<q-select
outlined
clearable
@ -240,7 +202,38 @@ const insuranceCompanyFilter = selectFilterOptionRefMod(
map-options
hide-selected
hide-bottom-space
class="col-5"
class="col-6"
input-debounce="0"
option-value="value"
option-label="label"
v-model="checkup.checkupResult"
:dense="dense"
:readonly="readonly || checkup.statusSave"
:options="checkupResultsOptions"
:hide-dropdown-icon="readonly || checkup.statusSave"
:for="`${prefixId}-select-health-checkresult`"
:label="$t('customerEmployee.formHealthCheck.result')"
@filter="checkupResultsFilter"
>
<template v-slot:no-option>
<q-item>
<q-item-section class="text-grey">
{{ $t('general.noData') }}
</q-item-section>
</q-item>
</template>
</q-select>
<q-select
outlined
clearable
use-input
fill-input
emit-value
map-options
hide-selected
hide-bottom-space
class="col-6"
input-debounce="0"
option-value="value"
option-label="label"
@ -261,47 +254,44 @@ const insuranceCompanyFilter = selectFilterOptionRefMod(
</q-item>
</template>
</q-select>
<q-select
outlined
clearable
use-input
fill-input
emit-value
map-options
hide-selected
hide-bottom-space
option-value="id"
input-debounce="0"
option-label="name"
class="col-2"
v-model="checkup.provinceId"
:dense="dense"
:readonly="readonly || checkup.statusSave"
:label="$t('form.province')"
:hide-dropdown-icon="readonly || checkup.statusSave"
:for="`${prefixId}-select-province`"
:options="provinceOptions"
@filter="provinceFilter"
>
<template v-slot:no-option>
<q-item>
<q-item-section class="text-grey">
{{ $t('general.noData') }}
</q-item-section>
</q-item>
</template>
</q-select>
<!-- @filter="provinceFilter" -->
<q-input
:dense="dense"
outlined
:readonly="readonly || checkup.statusSave"
hide-bottom-space
class="col-5"
class="col-12"
:label="$t('customerEmployee.formHealthCheck.hospital')"
v-model="checkup.hospitalName"
:for="`${prefixId}-input-hospital`"
/>
<div class="col">
<DatePicker
v-model="checkup.coverageStartDate"
:id="`${prefixId}-input-coverage-start-date`"
:readonly="readonly || checkup.statusSave"
clearable
/>
</div>
<div class="col">
<DatePicker
:label="$t('customerEmployee.formHealthCheck.coverageExpireDate')"
v-model="checkup.coverageExpireDate"
:id="`${prefixId}-input-coverage-expire-date`"
:readonly="readonly || checkup.statusSave"
:disabled-dates="
(date: Date) =>
date.getTime() <
((checkup.coverageStartDate &&
new Date(checkup.coverageStartDate).getTime()) ||
Date.now())
"
clearable
/>
</div>
<q-select
outlined
clearable
@ -311,7 +301,7 @@ const insuranceCompanyFilter = selectFilterOptionRefMod(
map-options
hide-selected
hide-bottom-space
class="col-5"
class="col-6"
input-debounce="0"
option-value="value"
option-label="label"
@ -343,53 +333,6 @@ const insuranceCompanyFilter = selectFilterOptionRefMod(
type="textarea"
:for="`${prefixId}-input-remark`"
/>
<DatePicker
:label="$t('customerEmployee.formHealthCheck.coverageStartDate')"
v-model="checkup.coverageStartDate"
class="col"
:id="`${prefixId}-input-coverage-start-date`"
:readonly="readonly || checkup.statusSave"
clearable
/>
<DatePicker
:label="$t('customerEmployee.formHealthCheck.coverageExpireDate')"
v-model="checkup.coverageExpireDate"
class="col"
:id="`${prefixId}-input-coverage-expire-date`"
:readonly="readonly || checkup.statusSave"
clearable
/>
<q-select
outlined
clearable
use-input
fill-input
emit-value
map-options
hide-selected
hide-bottom-space
class="col-7"
input-debounce="0"
option-label="label"
option-value="value"
v-model="checkup.insuranceCompany"
:dense="dense"
:readonly="readonly || checkup.statusSave"
:hide-dropdown-icon="readonly || checkup.statusSave"
:options="insuranceCompanyOptions"
:for="`${prefixId}-select-province`"
:label="$t('customerEmployee.formHealthCheck.insuranceCompany')"
@filter="insuranceCompanyFilter"
>
<template v-slot:no-option>
<q-item>
<q-item-section class="text-grey">
{{ $t('general.noData') }}
</q-item-section>
</q-item>
</template>
</q-select>
</div>
</div>
</template>

View file

@ -1,7 +1,6 @@
<script setup lang="ts">
import { EmployeeOtherCreate } from 'stores/employee/types';
import {
AddButton,
EditButton,
DeleteButton,
SaveButton,
@ -18,6 +17,13 @@ defineProps<{
hideAction?: boolean;
}>();
defineEmits<{
(e: 'save'): void;
(e: 'edit'): void;
(e: 'delete'): void;
(e: 'undo'): void;
}>();
const employeeOther = defineModel<EmployeeOtherCreate>('employeeOther');
</script>
<template>
@ -73,9 +79,20 @@ const employeeOther = defineModel<EmployeeOtherCreate>('employeeOther');
:readonly="readonly || employeeOther.statusSave"
hide-bottom-space
:label="$t('customerEmployee.formFamily.citizenId')"
class="col"
class="col-4"
v-model="employeeOther.citizenId"
/>
<q-input
:for="`${prefixId}-input-citizen-id`"
:dense="dense"
outlined
:readonly="readonly || employeeOther.statusSave"
hide-bottom-space
:label="$t('customerEmployee.formFamily.telephoneNo')"
class="col-3"
v-model="employeeOther.telephoneNo"
/>
</div>
<div class="col-12 app-text-muted-2">
<q-icon size="xs" class="q-mr-xs" name="mdi-human-male" />
@ -90,7 +107,13 @@ const employeeOther = defineModel<EmployeeOtherCreate>('employeeOther');
hide-bottom-space
class="col-md-3 col-6"
:label="$t('form.firstName')"
v-model="employeeOther.fatherFirstName"
:model-value="employeeOther.fatherFirstName"
@update:model-value="
(v) =>
typeof v === 'string' && employeeOther
? (employeeOther.fatherFirstName = v.trim())
: ''
"
/>
<q-input
:for="`${prefixId}-input-father-last-name`"
@ -100,7 +123,13 @@ const employeeOther = defineModel<EmployeeOtherCreate>('employeeOther');
hide-bottom-space
class="col-md-3 col-6"
:label="$t('form.lastName')"
v-model="employeeOther.fatherLastName"
:model-value="employeeOther.fatherLastName"
@update:model-value="
(v) =>
typeof v === 'string' && employeeOther
? (employeeOther.fatherLastName = v.trim())
: ''
"
/>
<q-input
:for="`${prefixId}-input-father-first-name-en`"
@ -110,7 +139,13 @@ const employeeOther = defineModel<EmployeeOtherCreate>('employeeOther');
hide-bottom-space
class="col-md-3 col-6"
:label="$t('form.firstNameEN')"
v-model="employeeOther.fatherFirstNameEN"
:model-value="employeeOther.fatherFirstNameEN"
@update:model-value="
(v) =>
typeof v === 'string' && employeeOther
? (employeeOther.fatherFirstNameEN = v.trim())
: ''
"
/>
<q-input
:for="`${prefixId}-input-father-last-name-en`"
@ -120,17 +155,13 @@ const employeeOther = defineModel<EmployeeOtherCreate>('employeeOther');
hide-bottom-space
class="col-md-3 col-6"
:label="$t('form.lastNameEN')"
v-model="employeeOther.fatherLastNameEN"
/>
<q-input
:for="`${prefixId}-input-father-birthplace`"
:dense="dense"
outlined
:readonly="readonly || employeeOther.statusSave"
hide-bottom-space
class="col-6"
:label="$t('customerEmployee.formFamily.fatherBirthPlace')"
v-model="employeeOther.fatherBirthPlace"
:model-value="employeeOther.fatherLastNameEN"
@update:model-value="
(v) =>
typeof v === 'string' && employeeOther
? (employeeOther.fatherLastNameEN = v.trim())
: ''
"
/>
</div>
@ -147,7 +178,13 @@ const employeeOther = defineModel<EmployeeOtherCreate>('employeeOther');
hide-bottom-space
class="col-md-3 col-6"
:label="$t('form.firstName')"
v-model="employeeOther.motherFirstName"
:model-value="employeeOther.motherFirstName"
@update:model-value="
(v) =>
typeof v === 'string' && employeeOther
? (employeeOther.motherFirstName = v.trim())
: ''
"
/>
<q-input
:for="`${prefixId}-input-mother-last-name`"
@ -157,7 +194,13 @@ const employeeOther = defineModel<EmployeeOtherCreate>('employeeOther');
hide-bottom-space
class="col-md-3 col-6"
:label="$t('form.lastName')"
v-model="employeeOther.motherLastName"
:model-value="employeeOther.motherLastName"
@update:model-value="
(v) =>
typeof v === 'string' && employeeOther
? (employeeOther.motherLastName = v.trim())
: ''
"
/>
<q-input
:for="`${prefixId}-input-mother-first-name-en`"
@ -167,7 +210,13 @@ const employeeOther = defineModel<EmployeeOtherCreate>('employeeOther');
hide-bottom-space
class="col-md-3 col-6"
:label="$t('form.firstNameEN')"
v-model="employeeOther.motherFirstNameEN"
:model-value="employeeOther.motherFirstNameEN"
@update:model-value="
(v) =>
typeof v === 'string' && employeeOther
? (employeeOther.motherFirstNameEN = v.trim())
: ''
"
/>
<q-input
:for="`${prefixId}-input-mother-last-name-en`"
@ -177,17 +226,13 @@ const employeeOther = defineModel<EmployeeOtherCreate>('employeeOther');
hide-bottom-space
class="col-md-3 col-6"
:label="$t('form.lastNameEN')"
v-model="employeeOther.motherLastNameEN"
/>
<q-input
:for="`${prefixId}-input-mother-birthplace`"
:dense="dense"
outlined
:readonly="readonly || employeeOther.statusSave"
hide-bottom-space
class="col-6"
:label="$t('customerEmployee.formFamily.motherBirthPlace')"
v-model="employeeOther.motherBirthPlace"
:model-value="employeeOther.motherLastNameEN"
@update:model-value="
(v) =>
typeof v === 'string' && employeeOther
? (employeeOther.motherLastNameEN = v.trim())
: ''
"
/>
</div>
</div>

View file

@ -1,36 +1,48 @@
<script setup lang="ts">
import { ref, watch, onMounted } from 'vue';
import { QSelect } from 'quasar';
import { selectFilterOptionRefMod } from 'stores/utils';
import { dateFormat, parseAndFormatDate } from 'src/utils/datetime';
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { selectFilterOptionRefMod } from 'stores/utils';
import { calculateAge, disabledAfterToday } from 'src/utils/datetime';
import useOptionStore from 'stores/options';
import { watch } from 'vue';
import { onMounted } from 'vue';
import DatePicker from '../shared/DatePicker.vue';
const optionStore = useOptionStore();
const { locale } = useI18n();
const firstName = defineModel<string>('firstName');
const lastName = defineModel<string>('lastName');
const prefixName = defineModel<string>('prefixName');
const birthCountry = defineModel<string>('birthCountry');
const previousPassportRef = defineModel<string>('previousPassportRef');
const issuePlace = defineModel<string>('issuePlace');
const issueCountry = defineModel<string>('issueCountry');
const issueDate = defineModel<Date | null | string>('issueDate');
const type = defineModel<string>('type');
const expireDate = defineModel<Date>('expireDate');
const birthDate = defineModel<Date>('birthDate');
const workerStatus = defineModel<string>('workerStatus');
const nationality = defineModel<string>('nationality');
const gender = defineModel<string>('gender');
const passportType = defineModel<string>('passportType');
const lastNameEN = defineModel<string>('lastNameEn');
const lastName = defineModel<string>('lastName');
const middleNameEN = defineModel<string>('middleNameEn');
const middleName = defineModel<string>('middleName');
const firstNameEN = defineModel<string>('firstNameEn');
const firstName = defineModel<string>('firstName');
const namePrefix = defineModel<string>('namePrefix');
const passportNumber = defineModel<string>('passportNumber');
const passportIssueDate = defineModel<Date | null | string>(
'passportIssueDate',
);
const passportExpiryDate = defineModel<Date | null | string>(
'passportExpiryDate',
);
const passportIssuingCountry = defineModel<string>('passportIssuingCountry');
const passportIssuingPlace = defineModel<string>('passportIssuingPlace');
const previousPassportReference = defineModel<string>(
'previousPassportReference',
);
const genderOptions = ref<Record<string, unknown>[]>([]);
let genderFilter: (
value: string,
update: (callbackFn: () => void, afterFn?: (ref: QSelect) => void) => void,
) => void;
const nationalityOptions = ref<Record<string, unknown>[]>([]);
let nationalityFilter: (
value: string,
update: (callbackFn: () => void, afterFn?: (ref: QSelect) => void) => void,
) => void;
const props = defineProps<{
title?: string;
@ -64,11 +76,17 @@ let prefixNameFilter: (
update: (callbackFn: () => void, afterFn?: (ref: QSelect) => void) => void,
) => void;
const workerStatusOptions = ref<Record<string, unknown>[]>([]);
let workerStatusFilter: (
value: string,
update: (callbackFn: () => void, afterFn?: (ref: QSelect) => void) => void,
) => void;
function matPreFixName() {
if (!gender.value) return;
if (gender.value === 'male') prefixName.value = 'mr';
else prefixName.value = 'mrs';
if (gender.value === 'male') namePrefix.value = 'mr';
else namePrefix.value = 'mrs';
}
onMounted(() => {
@ -93,6 +111,24 @@ onMounted(() => {
prefixNameOptions,
'label',
);
workerStatusFilter = selectFilterOptionRefMod(
ref(optionStore.globalOption?.workerStatus),
workerStatusOptions,
'label',
);
genderFilter = selectFilterOptionRefMod(
ref(optionStore.globalOption?.gender),
genderOptions,
'label',
);
nationalityFilter = selectFilterOptionRefMod(
ref(optionStore.globalOption?.nationality),
nationalityOptions,
'label',
);
}
matPreFixName();
@ -118,11 +154,29 @@ watch(
passportIssuingCountryOptions,
'label',
);
workerStatusFilter = selectFilterOptionRefMod(
ref(optionStore.globalOption?.workerStatus),
workerStatusOptions,
'label',
);
genderFilter = selectFilterOptionRefMod(
ref(optionStore.globalOption?.gender),
genderOptions,
'label',
);
nationalityFilter = selectFilterOptionRefMod(
ref(optionStore.globalOption?.nationality),
nationalityOptions,
'label',
);
},
);
watch(
() => prefixName.value,
() => namePrefix.value,
(v) => {
if (v === 'mr') gender.value = 'male';
else if (v !== '') gender.value = 'female';
@ -140,10 +194,7 @@ watch(
<template>
<div class="row col-12">
<div
v-if="!ocr && !hideTitle"
class="col-12 q-pb-sm text-weight-bold text-body1"
>
<div v-if="!hideTitle" class="col-12 q-pb-sm text-weight-bold text-body1">
<q-icon
flat
size="xs"
@ -156,7 +207,6 @@ watch(
</div>
<div
v-if="!ocr"
class="col-12 row justify-between items-center q-pb-sm text-weight-bold"
>
<div class="app-text-muted">
@ -170,20 +220,19 @@ watch(
<div class="row q-col-gutter-sm">
<div
v-if="!ocr"
class="col row justify-center q-col-gutter-sml"
style="max-height: 50%"
v-if="!ocr"
>
<div style="border: 1px dashed">
<q-avatar
square
size="100px"
font-size="50px"
color="grey-4"
text-color="grey"
icon="mdi-image-outline"
/>
</div>
<q-avatar
style="border: 1px dashed; border-color: black"
square
size="100px"
font-size="50px"
color="grey-4"
text-color="grey"
icon="mdi-image-outline"
/>
</div>
<div
class="row q-col-gutter-sm"
@ -201,22 +250,220 @@ watch(
input-debounce="0"
option-value="value"
option-label="label"
v-model="passportType"
:class="{ 'col-12': ocr, 'col-md-3': !ocr }"
class="col-6"
:dense="dense"
:readonly="readonly"
:options="workerStatusOptions"
:hide-dropdown-icon="readonly"
:options="passportTypeOptions"
:for="`${prefixId}-select-passport-type`"
:label="$t('customerEmployee.form.passportType')"
:for="`${prefixId}-select-visa-type`"
:label="$t('customerEmployee.form.workerType')"
@filter="workerStatusFilter"
:model-value="readonly ? workerStatus || '-' : workerStatus"
@update:model-value="
(v) => (typeof v === 'string' ? (workerStatus = v) : '')
"
@clear="workerStatus = ''"
:rules="[
(val) =>
(val && val.length > 0) ||
$t('form.error.selectField', {
field: $t('customerEmployee.form.passportType'),
}),
(val) => (val && val.length > 0) || $t('form.error.required'),
]"
@filter="passportTypeFilter"
>
<template v-slot:no-option>
<q-item>
<q-item-section class="text-grey">
{{ $t('general.noData') }}
</q-item-section>
</q-item>
</template>
</q-select>
<q-input
:for="`${prefixId}-input-previous-passport-Number`"
:dense="dense"
outlined
:readonly="readonly"
hide-bottom-space
class="col-6"
:label="$t('customerEmployee.form.previousPassportNumber')"
v-model="previousPassportRef"
:rules="[
(v) =>
(!!v && v.length === 6) ||
$t('form.error.requireLength', { msg: 6 }),
]"
/>
<q-select
outlined
use-input
fill-input
emit-value
map-options
hide-selected
hide-bottom-space
input-debounce="0"
option-label="label"
option-value="value"
hide-dropdown-icon
class="col-2"
:dense="dense"
:readonly="readonly"
:options="prefixNameOptions"
:for="`${prefixId}-select-prefix-name`"
:label="$t('personnel.form.prefixName')"
@filter="prefixNameFilter"
:model-value="readonly ? namePrefix || '-' : namePrefix"
@update:model-value="
(v) => (typeof v === 'string' ? (namePrefix = v) : '')
"
@clear="namePrefix = ''"
>
<template v-slot:no-option>
<q-item>
<q-item-section class="text-grey">
{{ $t('general.noData') }}
</q-item-section>
</q-item>
</template>
</q-select>
<q-input
:for="`${prefixId}-input-first-name`"
:dense="dense"
outlined
:readonly="readonly"
hide-bottom-space
class="col"
:label="$t('form.firstName')"
:model-value="readonly ? firstName || '-' : firstName"
@update:model-value="
(v) => (typeof v === 'string' ? (firstName = v.trim()) : '')
"
/>
<q-input
:for="`${prefixId}-input-middle-name`"
:dense="dense"
outlined
:readonly="readonly"
hide-bottom-space
class="col"
:label="$t('form.middleName')"
:model-value="readonly ? middleName || '-' : middleName"
@update:model-value="
(v) => (typeof v === 'string' ? (middleName = v.trim()) : '')
"
/>
<q-input
:for="`${prefixId}-input-last-name`"
:dense="dense"
outlined
:readonly="readonly"
hide-bottom-space
class="col"
:label="$t('form.lastName')"
:model-value="readonly ? lastName || '-' : lastName"
@update:model-value="
(v) => (typeof v === 'string' ? (lastName = v.trim()) : '')
"
/>
<q-input
:for="`${prefixId}-input-full-name`"
:dense="dense"
outlined
:readonly="readonly"
hide-bottom-space
class="col-12"
:disable="!readonly"
:label="$t('customer.table.fullname')"
:model-value="`${(prefixNameOptions.find((v) => v.value === namePrefix) || {}).label || ''} ${firstName || ''} ${lastName || ''}`"
/>
<q-input
:for="`${prefixId}-input-first-name-en`"
:dense="dense"
outlined
:readonly="readonly"
hide-bottom-space
class="col-4"
:label="$t('form.firstNameEN')"
:model-value="readonly ? firstNameEN || '-' : firstNameEN"
@update:model-value="
(v) => (typeof v === 'string' ? (firstNameEN = v.trim()) : '')
"
:rules="[
(val) => (val && val.length > 0) || $t('form.error.required'),
]"
/>
<q-input
:for="`${prefixId}-input-middle-name-en`"
:dense="dense"
outlined
:readonly="readonly"
hide-bottom-space
class="col-4"
:label="$t('form.middleNameEN')"
:model-value="readonly ? middleNameEN || '-' : middleNameEN"
@update:model-value="
(v) => (typeof v === 'string' ? (middleNameEN = v.trim()) : '')
"
/>
<q-input
:for="`${prefixId}-input-last-name-en`"
:dense="dense"
outlined
:readonly="readonly"
hide-bottom-space
class="col-4"
:label="$t('form.lastNameEN')"
:model-value="readonly ? lastNameEN || '-' : lastNameEN"
@update:model-value="
(v) => (typeof v === 'string' ? (lastNameEN = v.trim()) : '')
"
:rules="[
(val) => (val && val.length > 0) || $t('form.error.required'),
]"
/>
<q-input
:for="`${prefixId}-input-full-name-en`"
:dense="dense"
outlined
:readonly="readonly"
hide-bottom-space
class="col-12"
:label="$t('customer.table.fullnameEN')"
:disable="!readonly"
:model-value="`${(prefixNameOptions.find((v) => v.value === namePrefix) || {}).value || ''} ${firstNameEN || ''} ${lastNameEN || ''}`"
/>
<q-select
outlined
use-input
fill-input
emit-value
map-options
hide-selected
hide-bottom-space
input-debounce="0"
option-label="label"
option-value="value"
class="col-2"
dense
:readonly="readonly"
:options="genderOptions"
:hide-dropdown-icon="readonly"
:for="`${prefixId}-select-gender`"
:label="$t('form.gender')"
@filter="genderFilter"
:model-value="readonly ? gender || '-' : gender"
@update:model-value="
(v) => (typeof v === 'string' ? (gender = v) : '')
"
@clear="gender = ''"
>
<template v-slot:no-option>
<q-item>
@ -233,7 +480,7 @@ watch(
outlined
:readonly="readonly"
hide-bottom-space
:class="{ 'col-12': ocr, 'col-6': !ocr, 'col-md-3': !ocr }"
class="col-6 col-md-3"
:label="$t('customerEmployee.form.passportNo')"
v-model="passportNumber"
:rules="[
@ -241,106 +488,36 @@ watch(
]"
/>
<q-input
v-if="fullName && prefixNameOptions.length !== 0"
:for="`${prefixId}-input-last-name`"
:dense="dense"
outlined
<DatePicker
v-model="birthDate"
class="col-2"
:id="`${prefixId}-input-birth-date`"
:readonly="readonly"
hide-bottom-space
class="col"
:label="`${$t('personnel.form.firstName')}- ${$t('personnel.form.lastName')}`"
:model-value="`${prefixNameOptions.filter((v) => v.value === prefixName)[0].label || ''} ${firstName || ''} ${lastName || ''}`"
/>
<template v-if="!fullName">
<q-select
outlined
use-input
fill-input
emit-value
map-options
hide-selected
hide-bottom-space
input-debounce="0"
option-label="label"
option-value="value"
hide-dropdown-icon
:class="{ 'col-2': ocr, 'col-1': !ocr }"
:dense="dense"
:readonly="readonly"
:options="prefixNameOptions"
:for="`${prefixId}-select-prefix-name`"
:label="$t('personnel.form.prefixName')"
@filter="prefixNameFilter"
:model-value="readonly ? prefixName || '-' : prefixName"
@update:model-value="
(v) => (typeof v === 'string' ? (prefixName = v) : '')
"
@clear="prefixName = ''"
>
<template v-slot:no-option>
<q-item>
<q-item-section class="text-grey">
{{ $t('general.noData') }}
</q-item-section>
</q-item>
</template>
</q-select>
<q-input
:for="`${prefixId}-input-first-name`"
:dense="dense"
outlined
:readonly="readonly"
hide-bottom-space
class="col"
:label="$t('personnel.form.firstName')"
v-model="firstName"
/>
<q-input
:for="`${prefixId}-input-last-name`"
:dense="dense"
outlined
:readonly="readonly"
hide-bottom-space
class="col"
:label="$t('personnel.form.lastName')"
v-model="lastName"
/>
</template>
<q-input
:for="`${prefixId}-input-passport-ref`"
:dense="dense"
outlined
:readonly="readonly"
hide-bottom-space
:class="{ 'col-12': ocr, 'col-6': !ocr }"
:label="$t('customerEmployee.form.passportRef')"
:model-value="
readonly
? previousPassportReference || '-'
: previousPassportReference
"
@update:model-value="
(v) =>
typeof v === 'string' ? (previousPassportReference = v) : ''
"
/>
<q-input
:for="`${prefixId}-input-passport-place`"
:dense="dense"
outlined
:readonly="readonly"
hide-bottom-space
:class="{ 'col-12': ocr, 'col-6': !ocr, 'col-md-3': !ocr }"
:label="$t('customerEmployee.form.passportPlace')"
v-model="passportIssuingPlace"
:label="$t('form.birthDate')"
:disabled-dates="disabledAfterToday"
:rules="[
(val) => (val && val.length > 0) || $t('form.error.required'),
(val: string) =>
!!val ||
$t('form.error.selectField', { field: $t('form.birthDate') }),
]"
/>
<q-input
:for="`${prefixId}-input-age`"
:id="`${prefixId}-input-age`"
dense
outlined
readonly
:label="$t('personnel.age')"
class="col-2"
:model-value="
birthDate?.toString() === 'Invalid Date' ||
birthDate?.toString() === undefined
? ''
: calculateAge(birthDate)
"
/>
<q-select
outlined
clearable
@ -353,14 +530,69 @@ watch(
input-debounce="0"
option-value="value"
option-label="label"
:class="{ 'col-12': ocr, 'col-6': !ocr, 'col-md-3': !ocr }"
v-model="passportIssuingCountry"
class="col"
:dense="dense"
:readonly="readonly"
:options="nationalityOptions"
:hide-dropdown-icon="readonly"
:for="`${prefixId}-select-visa-type`"
:label="$t('general.nationality')"
@filter="nationalityFilter"
:model-value="readonly ? nationality || '-' : nationality"
@update:model-value="
(v) =>
typeof v === 'string' ? (birthCountry = nationality = v) : ''
"
@clear="nationality = ''"
:rules="[
(val) => (val && val.length > 0) || $t('form.error.required'),
]"
>
<template v-slot:no-option>
<q-item>
<q-item-section class="text-grey">
{{ $t('general.noData') }}
</q-item-section>
</q-item>
</template>
</q-select>
<q-input
:for="`${prefixId}-input-place-of-birth`"
:dense="dense"
outlined
:readonly="readonly"
hide-bottom-space
class="col-4"
:label="$t('customerEmployee.form.placeOfBirth')"
:model-value="optionStore.mapOption(birthCountry || '')"
@update:model-value="
(v) => (typeof v === 'string' ? (birthCountry = v) : '')
"
:rules="[(val) => !!val || $t('form.error.required')]"
/>
<q-select
outlined
clearable
use-input
fill-input
emit-value
map-options
hide-selected
hide-bottom-space
input-debounce="0"
option-value="value"
option-label="label"
class="col-6 col-md-4"
v-model="issueCountry"
:dense="dense"
:readonly="readonly"
:hide-dropdown-icon="readonly"
:options="passportIssuingCountryOptions"
:for="`${prefixId}-select-passport-country`"
:label="$t('customerEmployee.form.passportIssuer')"
@update:model-value="(v) => (issuePlace = v)"
:rules="[
(val) =>
(val && val.length > 0) ||
@ -378,12 +610,44 @@ watch(
</q-item>
</template>
</q-select>
<q-input
:for="`${prefixId}-input-passport-place`"
:dense="dense"
outlined
:readonly="readonly"
hide-bottom-space
class="col-4"
:label="$t('customerEmployee.form.passportPlace')"
:model-value="
readonly
? optionStore.mapOption(issuePlace || '') || '-'
: optionStore.mapOption(issuePlace || '')
"
@update:model-value="
(v) => (typeof v === 'string' ? (issuePlace = v) : '')
"
:rules="[
(val) => (val && val.length > 0) || $t('form.error.required'),
]"
/>
<DatePicker
:id="`${prefixId}-date-picker-passport-issueance`"
:label="$t('customerEmployee.form.passportIssueDate')"
v-model="passportIssueDate"
class="col-6"
:class="{ 'col-md-3': !ocr }"
v-model="issueDate"
@update:model-value="
(v) => {
if (!v) return;
if (!expireDate) return;
if (new Date(v).getTime() >= new Date(expireDate).getTime()) {
const newValue = new Date(v);
newValue.setDate(newValue.getDate() + 1);
expireDate = newValue;
}
}
"
class="col-6 col-md-3"
:readonly="readonly"
:rules="[
(val) =>
@ -396,9 +660,13 @@ watch(
<DatePicker
:id="`${prefixId}-date-picker-passport-expire`"
:label="$t('customerEmployee.form.passportExpireDate')"
v-model="passportExpiryDate"
class="col-6"
:class="{ 'col-md-3': !ocr }"
v-model="expireDate"
class="col-6 col-md-3"
:disabled-dates="
(date: Date) =>
date.getTime() <
((issueDate && new Date(issueDate).getTime()) || Date.now())
"
:readonly="readonly"
:rules="[
(val) =>

View file

@ -1,21 +1,18 @@
<script setup lang="ts">
import { QSelect } from 'quasar';
import { onMounted, reactive, ref } from 'vue';
import { dateFormat, parseAndFormatDate } from 'src/utils/datetime';
import { onMounted, reactive, ref, computed } from 'vue';
import useAddressStore, {
District,
Province,
SubDistrict,
} from 'stores/address';
import { useI18n } from 'vue-i18n';
import { selectFilterOptionRefMod } from 'stores/utils';
import useOptionStore from 'stores/options';
import { watch } from 'vue';
import DatePicker from '../shared/DatePicker.vue';
const optionStore = useOptionStore();
const { locale } = useI18n();
const adrressStore = useAddressStore();
const addressStore = useAddressStore();
const addrOptions = reactive<{
provinceOps: Province[];
@ -26,17 +23,25 @@ const addrOptions = reactive<{
districtOps: [],
subDistrictOps: [],
});
const arrivalAt = defineModel<string>('arrivalAt');
const visaType = defineModel<string>('visaType');
const visaNumber = defineModel<string>('visaNumber');
const visaIssueDate = defineModel<Date | null | string>('visaIssueDate');
const visaExpiryDate = defineModel<Date | null | string>('visaExpiryDate');
const visaIssuingPlace = defineModel<string>('visaIssuingPlace');
const visaStayUntilDate = defineModel<Date | null | string>(
'visaStayUntilDate',
);
const tm6Number = defineModel<string>('tm6Number');
const entryDate = defineModel<Date | null | string>('entryDate');
const arrivalTMNo = defineModel<string>('arrivalTmNo');
const arrivalTM = defineModel<string>('arrivalTm');
const mrz = defineModel<string>('mrz');
const entryCount = defineModel<number>('entryCount');
const issuePlace = defineModel<string>('issuePlace');
const issueCountry = defineModel<string>('issueCountry');
const issueDate = defineModel<Date | null | string>('visaIssueDate');
const type = defineModel<string>('visaType');
const expireDate = defineModel<Date>('expireDate');
const remark = defineModel<string>('remark');
const workerType = defineModel<string>('workerType');
const number = defineModel<string>('visaNumber');
const calculatedVisaDate = computed(() => {
if (!issueDate.value) return undefined;
return calculate90DayNext(issueDate.value);
});
defineProps<{
title?: string;
@ -46,12 +51,25 @@ defineProps<{
separator?: boolean;
typeCustomer?: string;
prefixId: string;
hideTitle?: boolean;
ocr?: boolean;
}>();
function calculate90DayNext(currentDate: Date | null | string) {
if (currentDate === null) return null;
const date =
typeof currentDate === 'string'
? new Date(currentDate)
: new Date(currentDate.getTime());
date.setDate(date.getDate() + 90);
return date;
}
async function fetchProvince() {
const result = await adrressStore.fetchProvince();
const result = await addressStore.fetchProvince();
if (result) addrOptions.provinceOps = result;
}
@ -66,12 +84,24 @@ let visaTypeFilter: (
update: (callbackFn: () => void, afterFn?: (ref: QSelect) => void) => void,
) => void;
const workerTypeOptions = ref<Record<string, unknown>[]>([]);
let workerTypeFilter: (
value: string,
update: (callbackFn: () => void, afterFn?: (ref: QSelect) => void) => void,
) => void;
onMounted(() => {
visaTypeFilter = selectFilterOptionRefMod(
ref(optionStore.globalOption?.nationality),
visaTypeOptions,
'label',
);
workerTypeFilter = selectFilterOptionRefMod(
ref(optionStore.globalOption?.workerType),
workerTypeOptions,
'label',
);
});
watch(
@ -82,148 +112,348 @@ watch(
visaTypeOptions,
'label',
);
workerTypeFilter = selectFilterOptionRefMod(
ref(optionStore.globalOption?.workerType),
workerTypeOptions,
'label',
);
},
);
</script>
<template>
<div class="row">
<div v-if="!ocr" class="col-12 q-pb-sm text-weight-bold text-body1">
<div class="row col-12">
<div v-if="!hideTitle" class="col-12 q-pb-sm text-weight-bold text-body1">
<q-icon
flat
size="xs"
class="q-pa-sm rounded q-mr-xs"
color="info"
name="mdi-notebook-outline"
name="mdi-passport"
style="background-color: var(--surface-3)"
/>
{{ $t(`${title}`) }}
{{ title }}
</div>
<div class="col-12 row q-col-gutter-sm">
<q-select
outlined
clearable
use-input
fill-input
emit-value
map-options
hide-selected
hide-bottom-space
input-debounce="0"
option-value="value"
option-label="label"
:class="{ 'col-4': !ocr, 'col-6': ocr }"
:dense="dense"
:readonly="readonly"
:options="visaTypeOptions"
:hide-dropdown-icon="readonly"
:for="`${prefixId}-select-visa-type`"
:label="$t('customerEmployee.form.visaType')"
@filter="visaTypeFilter"
:model-value="readonly ? visaType || '-' : visaType"
@update:model-value="
(v) => (typeof v === 'string' ? (visaType = v) : '')
"
@clear="visaType = ''"
<div
class="col-12 row justify-between items-center q-pb-sm text-weight-bold"
>
<div class="app-text-muted">
<slot name="expiryDate" />
</div>
<div>
<slot name="button"></slot>
</div>
</div>
<div class="row">
<div v-if="!ocr" class="col row justify-center" style="max-height: 50%">
<q-avatar
style="border: 1px dashed; border-color: black"
square
size="100px"
font-size="50px"
color="grey-4"
text-color="grey"
icon="mdi-image-outline"
/>
</div>
<div
class="row q-col-gutter-sm"
:class="{ 'col-10': !ocr, 'col-12': ocr }"
>
<template v-slot:no-option>
<q-item>
<q-item-section class="text-grey">
{{ $t('general.noData') }}
</q-item-section>
</q-item>
</template>
</q-select>
<!-- :rules="[
(val: string) =>
!!val || $t('selectValidate') + $t('formDialogInputVisaType'),
]" -->
<q-input
:for="`${prefixId}-input-visa-no`"
:dense="dense"
outlined
:readonly="readonly"
hide-bottom-space
:class="{ 'col-4': !ocr, 'col-6': ocr }"
:label="$t('customerEmployee.form.visaNo')"
:model-value="readonly ? visaNumber || '-' : visaNumber"
@update:model-value="
(v) => (typeof v === 'string' ? (visaNumber = v) : '')
"
/>
<!-- :rules="[
(val: string) =>
!!val || $t('inputValidate') + $t('formDialogInputVisaNo'),
]" -->
<DatePicker
:class="{ 'col-2': !ocr, 'col-6': ocr }"
:id="`${prefixId}-date-picker-visa-issuance`"
:readonly="readonly"
:label="$t('customerEmployee.form.visaIssuance')"
v-model="visaIssueDate"
clearable
/>
<DatePicker
:class="{ 'col-2': !ocr, 'col-6': ocr }"
:id="`${prefixId}-date-picker-visa-expire`"
:readonly="readonly"
:label="$t('customerEmployee.form.visaExpire')"
v-model="visaExpiryDate"
clearable
/>
<q-select
outlined
clearable
use-input
fill-input
emit-value
map-options
hide-selected
hide-bottom-space
input-debounce="0"
option-value="value"
option-label="label"
:class="{ 'col-4': !ocr, 'col-6': ocr }"
:dense="dense"
:readonly="readonly"
:options="workerTypeOptions"
:hide-dropdown-icon="readonly"
:for="`${prefixId}-select-visa-type`"
:label="$t('customerEmployee.form.workerType')"
@filter="workerTypeFilter"
:model-value="readonly ? workerType || '-' : workerType"
@update:model-value="
(v) => (typeof v === 'string' ? (workerType = v) : '')
"
@clear="workerType = ''"
:rules="[
(val) => (val && val.length > 0) || $t('form.error.required'),
]"
>
<template v-slot:no-option>
<q-item>
<q-item-section class="text-grey">
{{ $t('general.noData') }}
</q-item-section>
</q-item>
</template>
</q-select>
<q-input
:for="`${prefixId}-input-visa-place`"
:dense="dense"
outlined
:readonly="readonly"
hide-bottom-space
:class="{ 'col-5': !ocr, 'col-6': ocr }"
:label="$t('customerEmployee.form.visaPlace')"
:model-value="readonly ? visaIssuingPlace || '-' : visaIssuingPlace"
@update:model-value="
(v) => (typeof v === 'string' ? (visaIssuingPlace = v) : '')
"
/>
<!-- :rules="[
(val: string) =>
!!val || $t('selectValidate') + $t('formDialogInputVisaPlace'),
]" -->
<DatePicker
:class="{ 'col-4': !ocr, 'col-6': ocr }"
:id="`${prefixId}-date-picker-visa-until`"
:readonly="readonly"
:label="$t('customerEmployee.form.visaStayUntil')"
v-model="visaStayUntilDate"
clearable
/>
<q-input
:for="`${prefixId}-input-visa-no`"
:dense="dense"
outlined
:readonly="readonly"
hide-bottom-space
:class="{ 'col-4': !ocr, 'col-6': ocr }"
:label="$t('customerEmployee.form.visaNo')"
:model-value="readonly ? number || '-' : number"
@update:model-value="
(v) => (typeof v === 'string' ? (number = v) : '')
"
/>
<q-input
:for="`${prefixId}-input-tm6`"
:dense="dense"
outlined
:readonly="readonly"
hide-bottom-space
:class="{ 'col-5': !ocr, 'col-6': ocr }"
:label="$t('customerEmployee.form.visaTM6')"
:model-value="readonly ? tm6Number || '-' : tm6Number"
@update:model-value="
(v) => (typeof v === 'string' ? (tm6Number = v) : '')
"
@clear="tm6Number = ''"
/>
<!-- :rules="[
(val: string) =>
!!val || $t('inputValidate') + $t('formDialogInputVisaTM6'),
]" -->
<DatePicker
:class="{ 'col-4': !ocr, 'col-6': ocr }"
:id="`${prefixId}-date-picker-visa-enter`"
:readonly="readonly"
:label="$t('customerEmployee.form.visaEnter')"
v-model="entryDate"
clearable
/>
<q-input
:for="`${prefixId}-input-visa-place`"
:dense="dense"
outlined
:readonly="readonly"
hide-bottom-space
:class="{ 'col-4': !ocr, 'col-6': ocr }"
:label="$t('customerEmployee.form.visaPlace')"
:model-value="readonly ? issuePlace || '-' : issuePlace"
@update:model-value="
(v) => (typeof v === 'string' ? (issuePlace = v) : '')
"
:rules="[
(val) => (val && val.length > 0) || $t('form.error.required'),
]"
/>
<q-select
v-if="ocr"
outlined
clearable
use-input
fill-input
emit-value
map-options
hide-selected
hide-bottom-space
input-debounce="0"
option-value="value"
option-label="label"
:class="{ 'col-4': !ocr, 'col-6': ocr }"
:dense="dense"
:readonly="readonly"
:options="visaTypeOptions"
:hide-dropdown-icon="readonly"
:for="`${prefixId}-select-visa-type`"
:label="$t('customerEmployee.form.visaType')"
@filter="visaTypeFilter"
:model-value="readonly ? type || '-' : type"
@update:model-value="(v) => (typeof v === 'string' ? (type = v) : '')"
@clear="type = ''"
>
<template v-slot:no-option>
<q-item>
<q-item-section class="text-grey">
{{ $t('general.noData') }}
</q-item-section>
</q-item>
</template>
</q-select>
<div class="row q-col-gutter-sm">
<q-select
v-if="!ocr"
outlined
clearable
use-input
fill-input
emit-value
map-options
hide-selected
hide-bottom-space
input-debounce="0"
option-value="value"
option-label="label"
:class="{ 'col-4': !ocr, 'col-6': ocr }"
:dense="dense"
:readonly="readonly"
:options="visaTypeOptions"
:hide-dropdown-icon="readonly"
:for="`${prefixId}-select-visa-type`"
:label="$t('customerEmployee.form.visaType')"
@filter="visaTypeFilter"
:model-value="readonly ? type || '-' : type"
@update:model-value="
(v) => (typeof v === 'string' ? (type = v) : '')
"
@clear="type = ''"
>
<template v-slot:no-option>
<q-item>
<q-item-section class="text-grey">
{{ $t('general.noData') }}
</q-item-section>
</q-item>
</template>
</q-select>
<div class="col">
<DatePicker
:id="`${prefixId}-date-picker-visa-issuance`"
:readonly="readonly"
:label="$t('customerEmployee.form.visaIssuance')"
v-model="issueDate"
@update:model-value="
(v) => {
if (!v) return;
if (!expireDate) return;
if (new Date(v).getTime() >= new Date(expireDate).getTime()) {
const newValue = new Date(v);
newValue.setDate(newValue.getDate() + 1);
expireDate = newValue;
}
}
"
:rules="[
(val) => (val && val.length > 0) || $t('form.error.required'),
]"
clearable
/>
</div>
<div class="col">
<DatePicker
:id="`${prefixId}-date-picker-visa-expire`"
:readonly="readonly"
:label="$t('customerEmployee.form.visaExpire')"
v-model="expireDate"
:disabled-dates="
(date: Date) =>
date.getTime() <
((issueDate && new Date(issueDate).getTime()) || Date.now())
"
clearable
:rules="[
(val) => (val && val.length > 0) || $t('form.error.required'),
]"
/>
</div>
<div class="col">
<DatePicker
:id="`${prefixId}-date-picker-visa-issuance`"
:readonly
:disabled="!readonly"
:label="$t('customerEmployee.form.visa90Day')"
:model-value="calculatedVisaDate"
clearable
/>
</div>
</div>
<q-input
:for="`${prefixId}-input-visa-no`"
:dense="dense"
outlined
:readonly="readonly"
hide-bottom-space
class="col-4"
:label="$t('customerEmployee.form.arrivalCardNo')"
:model-value="readonly ? arrivalTMNo || '-' : arrivalTMNo"
@update:model-value="
(v) => (typeof v === 'string' ? (arrivalTMNo = v) : '')
"
:rules="[
(val) => (val && val.length > 0) || $t('form.error.required'),
]"
/>
<DatePicker
class="col-4"
:id="`${prefixId}-date-picker-visa-enter`"
:readonly="readonly"
:label="$t('customerEmployee.form.visaEnter')"
v-model="arrivalTM"
clearable
:rules="[
(val) => (val && val.length > 0) || $t('form.error.required'),
]"
/>
<q-input
:for="`${prefixId}-input-visa-no`"
:dense="dense"
outlined
:readonly="readonly"
hide-bottom-space
class="col-4"
:label="$t('customerEmployee.form.visaCheckpoint')"
:model-value="readonly ? arrivalAt || '-' : arrivalAt"
@update:model-value="
(v) => (typeof v === 'string' ? (arrivalAt = v) : '')
"
:rules="[
(val) => (val && val.length > 0) || $t('form.error.required'),
]"
/>
<q-select
outlined
clearable
use-input
fill-input
emit-value
map-options
hide-selected
hide-bottom-space
input-debounce="0"
option-value="value"
option-label="label"
class="col-4"
:dense="dense"
:readonly="readonly"
:options="visaTypeOptions"
:hide-dropdown-icon="readonly"
:for="`${prefixId}-select-issue-country`"
:label="$t('customerEmployee.form.issueCountry')"
@filter="visaTypeFilter"
:model-value="readonly ? issueCountry || '-' : issueCountry"
@update:model-value="
(v) => (typeof v === 'string' ? (issueCountry = v) : '')
"
@clear="type = ''"
:rules="[
(val) => (val && val.length > 0) || $t('form.error.required'),
]"
>
<template v-slot:no-option>
<q-item>
<q-item-section class="text-grey">
{{ $t('general.noData') }}
</q-item-section>
</q-item>
</template>
</q-select>
<q-input
:for="`${prefixId}-input-entry-count`"
:dense="dense"
outlined
:readonly="readonly"
hide-bottom-space
class="col-4"
:label="$t('customerEmployee.form.entryCount')"
v-model="entryCount"
type="number"
min="0"
:rules="[(val) => (!!val && val > 0) || $t('form.error.required')]"
/>
</div>
</div>
</div>
</template>

View file

@ -45,39 +45,6 @@ defineEmits<{
(e: 'undo', index: number): void;
}>();
function addData() {
// const canAdd = checkTabBeforeAdd(employeeWork.value || []);
const canAdd = true;
if (canAdd) {
employeeWork.value?.push({
workEndDate: null,
workPermitExpireDate: null,
workPermitIssuDate: null,
workPermitNo: '',
workplace: '',
jobType: '',
positionName: '',
ownerName: '',
remark: '',
});
if (employeeWork.value) {
tab.value = `tab${employeeWork.value.length - 1}`;
currentIndex.value = employeeWork.value.length - 1;
}
}
}
function removeData(index: number) {
if (!employeeWork.value) return;
if (employeeWork.value.length === 1) return;
employeeWork.value.splice(index, 1);
if (index) if (tab.value === `tab${index}`) tab.value = `tab${index - 1}`;
if (tab.value === `tab${employeeWork.value.length}`)
tab.value = `tab${employeeWork.value.length - 1}`;
}
onMounted(async () => {
tab.value = 'tab0';
});
@ -106,27 +73,6 @@ const workplaceFilter = selectFilterOptionRefMod(
<template>
<div class="row col-12">
<div class="col-12 q-pb-sm text-weight-bold text-body1 row items-center">
<q-icon
flat
size="xs"
class="q-pa-sm rounded q-mr-xs"
color="info"
name="mdi-briefcase-outline"
style="background-color: var(--surface-3)"
/>
{{ $t(`customerEmployee.formWorkHistory.title`) }}
<AddButton
v-if="currentIndex === -1 && !hideAction"
id="btn-add-bank"
icon-only
class="q-ml-sm"
type="button"
@click="addData"
:disabled="!(currentIndex === -1)"
/>
</div>
<div
v-for="(work, index) in employeeWork"
v-bind:key="index"
@ -177,36 +123,21 @@ const workplaceFilter = selectFilterOptionRefMod(
/>
</div>
</span>
<q-select
<q-input
:for="`${prefixId}-input-owner-name`"
:dense="dense"
outlined
clearable
use-input
fill-input
emit-value
map-options
hide-selected
:readonly="readonly || work.statusSave"
hide-bottom-space
class="col-6"
input-debounce="0"
option-label="label"
option-value="value"
v-model="work.jobType"
:dense="dense"
:readonly="readonly || work.statusSave"
:options="jobTypeOptions"
:hide-dropdown-icon="readonly"
:for="`${prefixId}-select-job-type`"
:label="$t('customerEmployee.formWorkHistory.jobType')"
@filter="jobTypeFilter"
>
<template v-slot:no-option>
<q-item>
<q-item-section class="text-grey">
{{ $t('general.noData') }}
</q-item-section>
</q-item>
</template>
</q-select>
:label="$t('customerEmployee.formWorkHistory.employerName')"
:model-value="work.ownerName"
@update:model-value="
(v) => (typeof v === 'string' ? (work.ownerName = v.trim()) : '')
"
/>
<q-select
outlined
clearable
@ -237,63 +168,7 @@ const workplaceFilter = selectFilterOptionRefMod(
</q-item>
</template>
</q-select>
<q-input
:for="`${prefixId}-input-work-end-date`"
:label="$t('general.remark')"
:dense="dense"
outlined
:readonly="readonly || work.statusSave"
hide-bottom-space
class="col-12"
v-model="work.remark"
type="textarea"
/>
<DatePicker
:label="$t('customerEmployee.formWorkHistory.workUntil')"
v-model="work.workEndDate"
class="col-3"
:id="`${prefixId}-input-work-until-date`"
:readonly="readonly || work.statusSave"
clearable
/>
<q-input
:for="`${prefixId}-input-work-permit-no`"
:dense="dense"
outlined
:readonly="readonly || work.statusSave"
hide-bottom-space
class="col-6"
:label="$t('customerEmployee.formWorkHistory.permitNo')"
v-model="work.workPermitNo"
/>
<DatePicker
:label="$t('customerEmployee.formWorkHistory.permitIssueDate')"
v-model="work.workPermitIssuDate"
class="col-3"
:id="`${prefixId}-date-picker-work-permit-issue-date`"
:readonly="readonly || work.statusSave"
clearable
/>
<DatePicker
:label="$t('customerEmployee.formWorkHistory.permitExpireDate')"
v-model="work.workPermitExpireDate"
class="col-3"
:id="`${prefixId}-date-picker-work-permit-expire-date`"
:readonly="readonly || work.statusSave"
clearable
/>
<q-input
:for="`${prefixId}-input-owner-name`"
:dense="dense"
outlined
:readonly="readonly || work.statusSave"
hide-bottom-space
class="col-5"
:label="$t('customerEmployee.formWorkHistory.employerName')"
v-model="work.ownerName"
/>
<q-select
outlined
clearable
@ -303,7 +178,38 @@ const workplaceFilter = selectFilterOptionRefMod(
map-options
hide-selected
hide-bottom-space
class="col-4"
class="col-6"
input-debounce="0"
option-label="label"
option-value="value"
v-model="work.jobType"
:dense="dense"
:readonly="readonly || work.statusSave"
:options="jobTypeOptions"
:hide-dropdown-icon="readonly"
:for="`${prefixId}-select-job-type`"
:label="$t('customerEmployee.formWorkHistory.jobType')"
@filter="jobTypeFilter"
>
<template v-slot:no-option>
<q-item>
<q-item-section class="text-grey">
{{ $t('general.noData') }}
</q-item-section>
</q-item>
</template>
</q-select>
<q-select
outlined
clearable
use-input
fill-input
emit-value
map-options
hide-selected
hide-bottom-space
class="col-6"
input-debounce="0"
option-value="value"
option-label="label"
@ -324,6 +230,70 @@ const workplaceFilter = selectFilterOptionRefMod(
</q-item>
</template>
</q-select>
<q-input
:for="`${prefixId}-input-owner-name`"
:dense="dense"
outlined
:readonly="readonly || work.statusSave"
hide-bottom-space
class="col-4"
:label="$t('customerEmployee.formWorkHistory.identityNo')"
v-model="work.identityNo"
mask="#############"
:rules="[
(val) =>
!val ||
(val.length === 13 && /^[0-9]+$/.test(val)) ||
$t('form.error.invalidCustomeMessage', {
msg: $t('form.error.requireLength', { msg: 13 }),
}),
]"
/>
<q-input
:for="`${prefixId}-input-owner-name`"
:dense="dense"
outlined
:readonly="readonly || work.statusSave"
hide-bottom-space
class="col-4"
:label="$t('customerEmployee.formWorkHistory.permitNo')"
v-model="work.workPermitNo"
/>
<q-input
:for="`${prefixId}-input-owner-name`"
:dense="dense"
outlined
:readonly="readonly || work.statusSave"
hide-bottom-space
class="col-4"
:label="$t('customerEmployee.formWorkHistory.permitIssuedAt')"
v-model="work.workPermitIssueAt"
/>
<DatePicker
:label="$t('customerEmployee.formWorkHistory.permitIssueDate')"
v-model="work.workPermitIssueDate"
class="col-3"
:id="`${prefixId}-date-picker-work-permit-issue-date`"
:readonly="readonly || work.statusSave"
clearable
/>
<DatePicker
:label="$t('customerEmployee.formWorkHistory.permitExpireDate')"
v-model="work.workPermitExpireDate"
class="col-3"
:id="`${prefixId}-date-picker-work-permit-expire-date`"
:readonly="readonly || work.statusSave"
:disabled-dates="
(date: Date) =>
date.getTime() <
((work.workPermitIssueDate &&
new Date(work.workPermitIssueDate).getTime()) ||
Date.now())
"
clearable
/>
</div>
</div>
</template>

View file

@ -0,0 +1,361 @@
<script setup lang="ts">
import { calculateAge, dateFormat } from 'src/utils/datetime';
import { calculateDaysUntilExpire } from 'stores/utils';
import { baseUrl } from 'src/stores/utils';
import useOptionStore from 'stores/options';
import { columnsEmployee as columns } from './customer';
import PersonCard from 'components/shared/PersonCard.vue';
import KebabAction from '../shared/KebabAction.vue';
import ExpirationDate from 'components/03_customer-management/ExpirationDate.vue';
import { AddButton } from 'components/button';
const optionStore = useOptionStore();
const pageSize = defineModel<number>('pageSize', { default: 30 });
const currentPage = defineModel<number>('currentPage', { default: 1 });
withDefaults(
defineProps<{
gridView?: boolean;
listEmployee: any[];
columnsEmployee?: any[];
fieldSelected?: string[];
inTable?: boolean;
addButton?: boolean;
prefixId?: string;
}>(),
{
gridView: false,
fieldSelected: () => [
'orderNumber',
'firstName',
'general.age',
'formDialogInputNationality',
'formDialogInputPassportNo',
'passportExpiryDate',
'visaExpireDate',
'beDue',
'branchLabel',
'action',
],
},
);
defineEmits<{
(e: 'add'): void;
(e: 'view', data: any): void;
(e: 'edit', data: any): void;
(e: 'delete', data: any): void;
(e: 'history', data: any): void;
(e: 'toggleStatus', data: any): void;
}>();
</script>
<template>
<q-table
flat
bordered
:grid="gridView"
:rows="listEmployee"
:columns="columnsEmployee || columns"
class="full-width"
card-container-class="q-col-gutter-md"
row-key="name"
:rows-per-page-options="[0]"
hide-pagination
:visible-columns="fieldSelected"
:no-data-label="$t('general.noDataTable')"
>
<template v-slot:header="props">
<q-tr
:style="`background-color: ${inTable ? 'hsla(var(--green-4-hsl) / 0.07)' : 'hsla(var(--info-bg) / 0.07'} `"
:props="props"
>
<q-th v-for="col in props.cols" :key="col.name" :props="props">
<span v-if="col.label === 'nameEmployee'">
{{ $t('fullname') }}
</span>
<span v-if="col.label === '' && !!addButton">
<AddButton
:id="`${prefixId || 'default'}-btn-add-employee`"
icon-only
@click.stop="() => $emit('add')"
/>
</span>
<span v-if="col.label !== ''">
{{ $t(col.label) }}
</span>
</q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr
:class="{
'app-text-muted': props.row.status === 'INACTIVE',
'status-active': props.row.status !== 'INACTIVE',
'status-inactive': props.row.status === 'INACTIVE',
}"
:props="props"
:id="`row-table-${props.row.firstNameEN}`"
@click="
() => {
// employeeFormState.drawerModal = true;
// employeeFormStore.assignFormDataEmployee(
// props.row.id,
// );
// $router.push(`/customer-management/${props.row.id}/branch`);
// openDialogInputForm('INFO', props.row.id);
}
"
>
<q-td class="text-center" v-if="fieldSelected.includes('orderNumber')">
{{ (currentPage - 1) * pageSize + props.rowIndex + 1 }}
</q-td>
<q-td v-if="fieldSelected.includes('firstName')">
<div class="row items-center" style="flex-wrap: nowrap">
<div
style="width: 50px; display: flex; margin-bottom: var(--size-2)"
>
<div
style="
border-radius: 50%;
border-style: solid;
border-width: 2px;
border-color: hsl(var(--pink-6-hsl));
padding: 3px;
"
>
<div class="full-width full-height">
<q-avatar size="md">
<q-img
:src="
`${baseUrl}/employee/${props.row.id}/image/${props.row.selectedImage}` ||
'/images/employee-avatar.png'
"
class="text-center"
:ratio="1"
>
<template #error>
<span>
<q-img
class="text-center"
:ratio="1"
src="/images/employee-avatar.png"
/>
</span>
</template>
</q-img>
<q-badge
class="absolute-bottom-right no-padding"
style="
border-radius: 50%;
min-width: 8px;
min-height: 8px;
"
:style="{
background: `var(--${props.row.status === 'INACTIVE' ? 'stone-5' : 'green-6'})`,
}"
></q-badge>
</q-avatar>
</div>
</div>
</div>
<div class="col text-left">
<div class="col">
{{ `${props.row.firstNameEN} ${props.row.lastNameEN}` || '-' }}
<q-icon
class="q-pl-xs"
:class="{
'symbol-gender': props.row.gender,
'symbol-gender__male': props.row.gender === 'male',
'symbol-gender__female': props.row.gender === 'female',
}"
:name="`mdi-gender-${props.row.gender === 'male' ? 'male' : 'female'}`"
width="24px"
/>
</div>
<div class="col app-text-muted">
{{ `${props.row.firstName} ${props.row.lastName}` || '-' }}
</div>
</div>
</div>
</q-td>
<q-td v-if="fieldSelected.includes('general.age')">
{{ calculateAge(props.row.dateOfBirth) || '-' }}
</q-td>
<q-td v-if="fieldSelected.includes('formDialogInputNationality')">
{{ optionStore.mapOption(props.row.nationality || '-') }}
</q-td>
<q-td v-if="fieldSelected.includes('formDialogInputPassportNo')">
{{
props.row.employeePassport[0]
? props.row.employeePassport[0].number
: '-'
}}
</q-td>
<q-td v-if="fieldSelected.includes('passportExpiryDate')">
{{
props.row.employeePassport[0]
? dateFormat(props.row.employeePassport[0].expireDate) || '-'
: '-'
}}
</q-td>
<q-td v-if="fieldSelected.includes('visaExpireDate')">
{{
props.row.employeeVisa[0]
? dateFormat(props.row.employeeVisa[0].expireDate) || '-'
: '-'
}}
</q-td>
<q-td v-if="fieldSelected.includes('beDue')">
<ExpirationDate
show-all-day
:expiration-date="
props.row.employeeVisa[0]
? props.row.employeeVisa[0].expireDate || undefined
: undefined
"
/>
</q-td>
<q-td v-if="fieldSelected.includes('branchLabel')">
<div class="row items-center" v-if="props.row.customerBranch">
<div class="col text-left">
<div class="col">
{{ props.row.customerBranch.code || '-' }}
</div>
<div class="col app-text-muted">
{{
$i18n.locale === 'eng'
? `${props.row.customerBranch.registerNameEN || ''}`
: `${props.row.customerBranch.registerName || ''}`
}}
</div>
</div>
</div>
</q-td>
<q-td>
<q-btn
:id="`btn-eye-${props.row.firstName}`"
icon="mdi-eye-outline"
size="sm"
dense
round
flat
@click.stop="$emit('view', props.row)"
/>
<KebabAction
v-if="!inTable"
:id-name="props.row.firstName"
:status="props.row.status"
@view="$emit('view', props.row)"
@edit="$emit('edit', props.row)"
@delete="$emit('delete', props.row)"
@change-status="$emit('toggleStatus', props.row)"
/>
</q-td>
</q-tr>
</template>
<template v-slot:item="props">
<div class="col-12 col-md-3 col-sm-6">
<PersonCard
:id="`card-${props.row.firstNameEN}`"
:field-selected="fieldSelected"
history
:prefix-id="props.row.firstNameEN ?? props.rowIndex"
:data="{
code: props.row.code,
name:
$i18n.locale === 'eng'
? `${props.row.firstNameEN} ${props.row.lastNameEN} `.trim()
: `${props.row.firstName} ${props.row.lastName} `.trim(),
img:
`${baseUrl}/employee/${props.row.id}/image/${props.row.selectedImage}` ||
'/images/employee-avatar.png',
fallbackImg: '/images/employee-avatar.png',
male: props.row.gender === 'male',
female: props.row.gender === 'female',
detail: [
{
icon: 'mdi-passport',
value: optionStore.mapOption(props.row.nationality),
},
{
icon: 'mdi-clock-outline',
value: props.row.dateOfBirth
? (calculateAge(props.row.dateOfBirth) ?? '')
: '',
},
],
}"
:tag="[
{
color: 'pink',
value: $t('customer.employee'),
},
]"
:disabled="props.row.status === 'INACTIVE'"
@history="() => $emit('history', props.row)"
@update-card="() => $emit('edit', props.row)"
@enter-card="() => $emit('view', props.row)"
@delete-card="() => $emit('delete', props.row)"
@toggle-status="() => $emit('toggleStatus', props.row)"
/>
</div>
</template>
</q-table>
</template>
<style scoped>
.branch-card__icon {
background-color: hsla(var(--_branch-card-bg) / 0.15);
border-radius: 50%;
padding: var(--size-1);
position: relative;
transform: rotate(45deg);
&::after {
content: ' ';
display: block;
block-size: 0.5rem;
aspect-ratio: 1;
position: absolute;
border-radius: 50%;
right: -0.1rem;
top: calc(50% - 0.25rem);
bottom: calc(50% - 0.25rem);
background-color: hsla(var(--_branch-status-color) / 1);
}
& :deep(.q-avatar) {
transform: rotate(-45deg);
color: hsla(var(--_branch-card-bg) / 1);
}
}
& .symbol-gender {
color: hsla(var(--_fg));
&.symbol-gender__male {
--_fg: var(--gender-male);
}
&.symbol-gender__female {
--_fg: var(--gender-female);
}
}
</style>

View file

@ -1,10 +1,13 @@
<script setup lang="ts">
import { calculateAge, dateFormat } from 'src/utils/datetime';
import { calculateDaysUntilExpire } from 'stores/utils';
import { baseUrl } from 'src/stores/utils';
import useOptionStore from 'stores/options';
import PersonCard from 'components/shared/PersonCard.vue';
import KebabAction from '../shared/KebabAction.vue';
import useOptionStore from 'stores/options';
import ExpirationDate from 'components/03_customer-management/ExpirationDate.vue';
import { AddButton } from 'components/button';
const optionStore = useOptionStore();
const pageSize = defineModel<number>('pageSize', { default: 30 });
@ -17,6 +20,8 @@ const prop = withDefaults(
columnsEmployee: any[];
fieldSelected?: string[];
inTable?: boolean;
addButton?: boolean;
prefixId?: string;
}>(),
{
gridView: false,
@ -27,7 +32,8 @@ const prop = withDefaults(
'formDialogInputNationality',
'formDialogInputPassportNo',
'passportExpiryDate',
'formDialogEmployeeNRCNo',
'visaExpireDate',
'beDue',
'branchLabel',
'action',
],
@ -35,9 +41,11 @@ const prop = withDefaults(
);
defineEmits<{
(e: 'add'): void;
(e: 'view', data: any): void;
(e: 'edit', data: any): void;
(e: 'delete', data: any): void;
(e: 'history', data: any): void;
(e: 'toggleStatus', data: any): void;
}>();
</script>
@ -55,17 +63,24 @@ defineEmits<{
:rows-per-page-options="[0]"
hide-pagination
:visible-columns="fieldSelected"
:no-data-label="$t('general.noDataTable')"
>
<template v-slot:header="props">
<q-tr
:style="`background-color: ${inTable ? '#F0FFF1' : 'hsla(var(--info-bg) / 0.07'} `"
:style="`background-color: ${inTable ? 'hsla(var(--green-4-hsl) / 0.07)' : 'hsla(var(--info-bg) / 0.07'} `"
:props="props"
>
<q-th v-for="col in props.cols" :key="col.name" :props="props">
<span v-if="col.label === 'nameEmployee'">
{{ $t('fullname') }}
</span>
<span v-if="col.label === '' && !!addButton">
<AddButton
:id="`${prefixId || 'default'}-btn-add-employee`"
icon-only
@click.stop="() => $emit('add')"
/>
</span>
<span v-if="col.label !== ''">
{{ $t(col.label) }}
</span>
@ -157,7 +172,7 @@ defineEmits<{
<div class="col text-left">
<div class="col">
{{ `${props.row.firstName} ${props.row.lastName}` || '-' }}
{{ `${props.row.firstNameEN} ${props.row.lastNameEN}` || '-' }}
<q-icon
class="q-pl-xs"
@ -171,7 +186,7 @@ defineEmits<{
/>
</div>
<div class="col app-text-muted">
{{ `${props.row.firstNameEN} ${props.row.lastNameEN}` || '-' }}
{{ `${props.row.firstName} ${props.row.lastName}` || '-' }}
</div>
</div>
</div>
@ -186,15 +201,38 @@ defineEmits<{
</q-td>
<q-td v-if="fieldSelected.includes('formDialogInputPassportNo')">
{{ props.row.passportNumber || '-' }}
{{
props.row.employeePassport[0]
? props.row.employeePassport[0].number
: '-'
}}
</q-td>
<q-td v-if="fieldSelected.includes('passportExpiryDate')">
{{ dateFormat(props.row.passportExpiryDate) || '-' }}
{{
props.row.employeePassport[0]
? dateFormat(props.row.employeePassport[0].expireDate) || '-'
: '-'
}}
</q-td>
<q-td v-if="fieldSelected.includes('formDialogEmployeeNRCNo')">
{{ props.row.nrcNo || '-' }}
<q-td v-if="fieldSelected.includes('visaExpireDate')">
{{
props.row.employeeVisa[0]
? dateFormat(props.row.employeeVisa[0].expireDate) || '-'
: '-'
}}
</q-td>
<q-td v-if="fieldSelected.includes('beDue')">
<ExpirationDate
show-all-day
:expiration-date="
props.row.employeeVisa[0]
? props.row.employeeVisa[0].expireDate || undefined
: undefined
"
/>
</q-td>
<q-td v-if="fieldSelected.includes('branchLabel')">
@ -206,8 +244,8 @@ defineEmits<{
<div class="col app-text-muted">
{{
$i18n.locale === 'eng'
? `${props.row.customerBranch.registerNameEN}`
: `${props.row.customerBranch.registerName}`
? `${props.row.customerBranch.registerNameEN || ''}`
: `${props.row.customerBranch.registerName || ''}`
}}
</div>
</div>
@ -225,6 +263,7 @@ defineEmits<{
@click.stop="$emit('view', props.row)"
/>
<KebabAction
v-if="!inTable"
:id-name="props.row.firstName"
:status="props.row.status"
@view="$emit('view', props.row)"
@ -325,4 +364,15 @@ defineEmits<{
--_fg: var(--gender-female);
}
}
.status-active {
--_branch-status-color: var(--green-6-hsl);
}
.status-inactive {
--_branch-status-color: var(--stone-5-hsl);
--_branch-badge-bg: var(--stone-5-hsl);
filter: grayscale(0.5);
opacity: 0.5;
}
</style>

View file

@ -10,9 +10,11 @@ import {
UndoButton,
} from 'components/button';
import { useI18n } from 'vue-i18n';
import useOptionStore from 'stores/options';
const { locale } = useI18n();
const optionStore = useOptionStore();
const optionsBranch = defineModel<{ id: string; name: string }[]>(
'optionsBranch',
{ default: [] },
@ -109,6 +111,7 @@ defineEmits<{
:id="`${prefixId}-select-employer-branch`"
:for="`${prefixId}-select-employer-branch`"
:use-input="!customerBranch"
autocomplete="off"
input-debounce="0"
:hide-dropdown-icon="readonly"
:dense="dense"
@ -162,11 +165,14 @@ defineEmits<{
</span>
{{
scope.opt.customer.customerType === 'CORP'
? scope.opt.customerName
? $i18n.locale === 'eng'
? scope.opt.registerNameEN
: scope.opt.registerName
: $i18n.locale === 'eng'
? `${scope.opt.firstNameEN} ${scope.opt.lastNameEN}` ||
? `${optionStore.mapOption(scope.opt.namePrefix)} ${scope.opt.firstNameEN} ${scope.opt.lastNameEN}` ||
'-'
: `${optionStore.mapOption(scope.opt.namePrefix)} ${scope.opt.firstName} ${scope.opt.lastName}` ||
'-'
: `${scope.opt.firstName} ${scope.opt.lastName}` || '-'
}}
({{ scope.opt.code }})
</span>
@ -217,10 +223,14 @@ defineEmits<{
</span>
{{
scope.opt.customer.customerType === 'CORP'
? scope.opt.customerName
? $i18n.locale === 'eng'
? scope.opt.registerNameEN
: scope.opt.registerName
: $i18n.locale === 'eng'
? `${scope.opt.firstNameEN} ${scope.opt.lastNameEN}` || '-'
: `${scope.opt.firstName} ${scope.opt.lastName}` || '-'
? `${optionStore.mapOption(scope.opt.namePrefix)} ${scope.opt.firstNameEN} ${scope.opt.lastNameEN}` ||
'-'
: `${optionStore.mapOption(scope.opt.namePrefix)} ${scope.opt.firstName} ${scope.opt.lastName}` ||
'-'
}}
({{ scope.opt.code }})
</div>
@ -293,11 +303,12 @@ defineEmits<{
:readonly="readonly"
class="col-6"
:label="$t('customerEmployee.form.nrcNo')"
:model-value="readonly ? nrcNo || '-' : nrcNo"
:model-value="nrcNo"
@update:model-value="(v) => (typeof v === 'string' ? (nrcNo = v) : '')"
:rules="[
(val) =>
(val && val.length === 13 && /[0-9]+/.test(val)) ||
!val ||
val.length === 13 ||
$t('form.error.invalidCustomeMessage', {
msg: $t('form.error.requireLength', { msg: 13 }),
}),

View file

@ -0,0 +1,794 @@
<script lang="ts" setup>
import { onMounted, ref, watch } from 'vue';
import { Icon } from '@iconify/vue/dist/iconify.js';
import { moveItemUp, moveItemDown, deleteItem } from 'src/stores/utils';
import { useI18n } from 'vue-i18n';
import useUserStore from 'src/stores/user';
import useOptionStore from 'src/stores/options';
import { baseUrl } from 'stores/utils';
import { getRole } from 'src/services/keycloak';
import {
WorkflowUserInTable,
WorkflowTemplatePayload,
WorkFlowPayloadStep,
} from 'src/stores/workflow-template/types';
import { User } from 'src/stores/user/types';
import SelectMenuWithSearch from '../shared/SelectMenuWithSearch.vue';
import ToggleButton from 'src/components/button/ToggleButton.vue';
import NoData from '../NoData.vue';
import SelectBranch from '../shared/select/SelectBranch.vue';
defineProps<{
readonly?: boolean;
onDrawer?: boolean;
}>();
const { t } = useI18n();
const userStore = useUserStore();
const optionStore = useOptionStore();
const userInTable = defineModel<WorkflowUserInTable[]>('userInTable', {
default: [],
});
const registerBranchId = defineModel('registerBranchId', { default: '' });
const flowData = defineModel<WorkflowTemplatePayload>('flowData', {
required: true,
default: {
status: 'CREATED',
name: '',
step: [],
},
});
const objectOptions = [
...(optionStore.globalOption?.agenciesType || []),
{ label: t('flow.customer'), value: 'customer' },
{ label: t('flow.officer'), value: 'officer' },
];
const options = ref(objectOptions);
const role = ref<string[]>([]);
const userList = ref<User[]>([]);
const responsiblePersonSearch = ref('');
async function getUserList(opts?: { query: string }) {
const resUser = await userStore.fetchList({
query: !!opts?.query ? opts.query : undefined,
});
if (resUser) userList.value = resUser.result;
}
// async function getUserById(responsiblePersonId: string) {
// const resUser = await userStore.fetchById(responsiblePersonId);
// if (resUser) userInTable.value.push(resUser);
// }
function selectResponsiblePerson(stepIndex: number, responsiblePerson: User) {
const currStep = flowData.value.step[stepIndex];
const existPersonIndex = currStep.responsiblePersonId?.findIndex(
(p) => p === responsiblePerson.id,
);
if (existPersonIndex === -1) {
currStep.responsiblePersonId?.push(responsiblePerson.id);
if (!userInTable.value[stepIndex]) {
userInTable.value[stepIndex] = {
name: flowData.value.step[stepIndex].name,
responsiblePerson: [],
};
}
userInTable.value[stepIndex]?.responsiblePerson.push({
id: responsiblePerson.id,
selectedImage: responsiblePerson.selectedImage,
gender: responsiblePerson.gender,
namePrefix: responsiblePerson.namePrefix,
firstName: responsiblePerson.firstName,
lastName: responsiblePerson.lastName,
firstNameEN: responsiblePerson.firstNameEN,
lastNameEN: responsiblePerson.lastNameEN,
code: responsiblePerson.code,
});
} else {
currStep.responsiblePersonId?.splice(Number(existPersonIndex), 1);
userInTable.value[stepIndex]?.responsiblePerson.splice(
Number(existPersonIndex),
1,
);
}
}
function selectItem(
val: Record<string, unknown>,
responsibleInstitution?: string[],
) {
if (!responsibleInstitution) return;
const existIndex = responsibleInstitution.findIndex((p) => p === val.value);
if (existIndex === -1) {
responsibleInstitution.push(val.value as string);
} else {
responsibleInstitution.splice(Number(existIndex), 1);
}
}
function optionSearch(val: string | null) {
if (val === '') {
options.value = objectOptions;
return;
}
const needle = val ? val.toLowerCase() : '';
options.value = objectOptions.filter(
(v: { label: string }) => v.label.toLowerCase().indexOf(needle) > -1,
);
}
defineEmits<{
(e: 'moveUp'): void;
(e: 'moveDown'): void;
(e: 'changeStatus'): void;
(e: 'triggerProperties', step: WorkFlowPayloadStep): void;
}>();
watch(
responsiblePersonSearch,
async () => await getUserList({ query: responsiblePersonSearch.value }),
);
onMounted(async () => {
role.value = getRole() || [];
await getUserList();
await userStore.fetchHqOption();
});
</script>
<template>
<div class="row col-12">
<section
:id="`form-flow-template-${onDrawer ? 'drawer' : 'dialog'}`"
class="col-12 q-pb-sm text-weight-bold text-body1 row items-center"
>
<q-icon
flat
size="xs"
class="q-pa-sm rounded q-mr-xs"
color="info"
name="mdi-cogs"
style="background-color: var(--surface-3)"
/>
{{ $t(`general.name`, { msg: $t('flow.title') }) }}
<span class="row q-ml-lg items-center text-weight-regular text-body2">
<ToggleButton
class="q-mr-sm"
two-way
:model-value="flowData.status !== 'INACTIVE'"
@click="
() => {
onDrawer
? $emit('changeStatus')
: flowData.status !== 'INACTIVE'
? (flowData.status = 'INACTIVE')
: (flowData.status = 'CREATED');
}
"
/>
{{ $t('status.title') }}
</span>
</section>
<section class="col-12 row q-col-gutter-sm">
<SelectBranch
v-if="role.includes('system')"
:label="$t('branch.form.code')"
:readonly
code-only
v-model:value="registerBranchId"
class="col-md-3 col-12"
required
/>
<q-input
:readonly
bg-color="transparent"
outlined
dense
class="col"
id="input-flow-template-name"
v-model="flowData.name"
hide-bottom-space
:label="$t(`general.name`, { msg: $t('flow.step') })"
:rules="[(val: string) => !!val || $t('form.error.required')]"
/>
</section>
<!-- SEC: Step -->
<section
:id="`form-flow-step-${onDrawer ? 'drawer' : 'dialog'}`"
class="col-12 q-pb-sm q-pt-xl text-weight-bold text-body1 row items-center"
>
<q-icon
flat
size="xs"
class="q-pa-sm rounded q-mr-xs"
color="info"
name="mdi-note-edit-outline"
style="background-color: var(--surface-3)"
/>
{{ $t(`flow.processStep`) }}
</section>
<section
v-if="flowData.step.length === 0"
class="col-12 surface-2 rounded bordered column items-center justify-center q-pa-xl"
>
<NoData class="col" />
</section>
<section v-else class="col-12 q-gutter-y-md">
<template v-for="(step, index) in flowData.step" :key="index">
<div class="bordered rounded">
<q-expansion-item
for="item-up"
id="item-up"
dense
switch-toggle-side
default-opened
expand-icon="mdi-chevron-down-circle"
header-class="expansion-rounded surface-2"
header-style="border-top-left-radius: var(--radius-2); border-top-right-radius: var(--radius-2)"
>
<template v-slot:header>
<div class="column full-width" @keyup.stop @click.stop>
<div class="row items-center q-py-sm full-width">
<q-btn
v-if="!readonly"
id="btn-work-up-product"
for="btn-work-up-product"
icon="mdi-arrow-up"
dense
flat
round
:disable="index === 0"
style="color: hsl(var(--text-mute-2))"
@click.stop="
moveItemUp(flowData.step, index);
moveItemUp(userInTable, index);
"
/>
<q-btn
v-if="!readonly"
id="btn-work-down-product"
for="btn-work-down-product"
icon="mdi-arrow-down"
dense
flat
round
class="q-mx-sm"
:disable="index === flowData.step.length - 1"
style="color: hsl(var(--text-mute-2))"
@click.stop="
moveItemDown(flowData.step, index);
moveItemDown(userInTable, index);
"
/>
<q-input
:bg-color="readonly ? 'transparent' : ''"
:prefix="`${$t('flow.stepNo')} ${index + 1}: `"
dense
outlined
:readonly
:id="`input-flow-step-name-${index}-${onDrawer ? 'drawer' : 'dialog'}`"
:for="`input-flow-step-name-${index}-${onDrawer ? 'drawer' : 'dialog'}`"
class="col q-ml-md"
:placeholder="$t('general.no', { msg: $t('flow.step') })"
v-model="step.name"
hide-bottom-space
:rules="[
(val: string) => !!val || $t('form.error.required'),
]"
/>
<!-- <div
:for="`select-work-name-${index + 1}`"
class="col q-py-sm q-px-md"
style="background-color: var(--surface-1); z-index: 2"
@click="() => (readonly ? '' : fetchListOfWork())"
>
<span class="text-body2" style="color: var(--foreground)">
{{ $t('productService.service.work') }} {{ index + 1 }} :
<span class="app-text-muted-2">
{{
workName
? workName
: $t('productService.service.workName')
}}
</span>
</span>
<q-menu
v-if="!readonly"
fit
ref="refMenu"
self="top left"
anchor="bottom left"
>
<q-item>
<div
class="full-width flex items-center justify-between"
>
{{ $t('productService.service.workName') }}
<q-btn
dense
unelevated
class="bordered q-px-sm text-capitalize"
style="
border-radius: var(--radius-2);
color: hsl(var(--info-bg));
"
@click.stop="
() => {
refMenu.hide();
$emit('manageWorkName');
}
"
>
<q-icon name="mdi-cog" size="xs" class="q-mr-sm" />
{{ $t('general.manage') }}
</q-btn>
</div>
</q-item>
<q-item
@click="workName = item.name"
clickable
v-for="(item, index) in workNameItems"
:key="index"
>
<div class="full-width flex items-center">
<q-icon
v-if="workName === item.name"
name="mdi-checkbox-marked"
size="xs"
color="primary"
class="q-mr-sm"
/>
<q-icon
v-else
name="mdi-checkbox-blank-outline"
size="xs"
style="color: hsl(var(--text-mute))"
class="q-mr-sm"
/>
{{ item.name }}
</div>
</q-item>
</q-menu>
</div> -->
<q-btn
v-if="!readonly"
id="btn-delete-work"
icon="mdi-trash-can-outline"
dense
flat
round
padding="0"
class="q-ml-md"
color="negative"
@click="deleteItem(flowData.step, index)"
>
<q-tooltip>{{ $t('general.delete') }}</q-tooltip>
</q-btn>
</div>
</div>
</template>
<section class="q-px-md surface-2 q-py-sm">
<div
:class="{ 'surface-1 rounded bordered': readonly }"
style="border: 1px solid transparent"
>
<div class="row q-col-gutter-sm">
<q-input
:bg-color="readonly ? 'transparent' : ''"
:readonly
class="col-md-6 col-12"
type="textarea"
dense
outlined
:label="$t('general.detail')"
:model-value="!readonly ? step.detail : step.detail || '-'"
@update:model-value="
(v) => (step.detail = v?.toString() || '')
"
/>
<div class="col-md-6 col-12">
<div
class="surface-1 rounded bordered full-height"
style="padding-inline: 12px"
:style="readonly ? 'border: 1px solid transparent;' : ''"
>
<section
class="row items-center q-pt-xs justify-between relative-position"
>
<div
class="app-text-muted-2"
:style="
step.attributes?.properties &&
step.attributes?.properties.length > 0
? 'font-size: 10px'
: ''
"
>
{{ $t('general.properties') }}
</div>
<q-btn
v-if="!readonly"
id="btn-add-work-product"
class="text-capitalize rounded absolute-top-right"
flat
dense
padding="4px 8px"
style="color: hsl(var(--info-bg)); top: 4px"
@click.stop="$emit('triggerProperties', step)"
>
<Icon
icon="basil:settings-adjust-solid"
width="20.08px"
style="color: hsl(var(--info-bg))"
/>
</q-btn>
</section>
<section class="row q-gutter-sm q-pb-sm scroll">
<span
v-for="(att, i) in step.attributes?.properties"
:key="i"
class="surface-2 bordered rounded q-px-xs"
>
{{ optionStore.mapOption(att.fieldName ?? '') }}
</span>
</section>
</div>
</div>
<!-- RESPONSIBLE-PERSON -->
<q-select
v-if="step.responsiblePersonId"
behavior="menu"
:bg-color="readonly ? 'transparent' : ''"
:readonly
outlined
dense
v-model="step.responsiblePersonId"
multiple
:options="[1, 2, 3]"
hide-bottom-space
option-label="label"
option-value="value"
emit-value
:label="$t('flow.responsiblePerson')"
class="col-md-6 col-12"
:hide-dropdown-icon="readonly"
>
<template v-slot:selected-item="scope">
<div class="column full-width">
<div
class="row items-center no-wrap"
v-for="person in userInTable[
index
]?.responsiblePerson.filter(
(p) => p.id === scope.opt,
)"
:key="person.id"
>
<q-avatar class="q-ml-sm" size="md">
<q-img
class="text-center"
:ratio="1"
:src="`${baseUrl}/user/${person.id}/profile-image/${person.selectedImage}`"
>
<template #error>
<div
class="no-padding full-width full-height flex items-center justify-center"
:style="`${person.gender ? 'background: white' : 'background: linear-gradient(135deg,rgba(43, 137, 223, 1) 0%, rgba(230, 51, 81, 1) 100%);'}`"
>
<q-img
v-if="person.gender"
:src="
person.gender === 'male'
? '/no-img-man.png'
: '/no-img-female.png'
"
/>
<q-icon
v-else
size="sm"
name="mdi-account-outline"
style="color: white"
/>
</div>
</template>
</q-img>
</q-avatar>
<div
class="column q-pl-md"
style="color: var(--foreground)"
>
<span>
{{
`${optionStore.mapOption(person.namePrefix || '')} ${
$i18n.locale === 'eng'
? person.firstNameEN
: person.firstName
} ${
$i18n.locale === 'eng'
? person.lastNameEN
: person.lastName
}`
}}
</span>
<span class="text-caption app-text-muted">
{{ person.code }}
</span>
</div>
</div>
</div>
</template>
<template v-slot:option></template>
<q-menu v-if="!readonly" :offset="[0, 4]">
<q-list>
<q-item>
<q-input
for="input-search"
outlined
dense
:label="$t('general.search')"
class="col responsible-search"
:bg-color="$q.dark.isActive ? 'dark' : 'white'"
v-model="responsiblePersonSearch"
debounce="200"
>
<template #prepend>
<q-icon name="mdi-magnify" />
</template>
</q-input>
</q-item>
<span class="text-caption app-text-muted-2 q-px-md">
{{ $t('general.people') }}
</span>
<q-item
v-if="userList.length === 0"
class="app-text-muted q-px-lg"
>
{{ $t('general.noData') }}
</q-item>
<q-item
v-for="(person, i) in userList"
dense
:key="i"
clickable
class="column"
@click.stop="selectResponsiblePerson(index, person)"
>
<div class="row items-center no-wrap">
<q-checkbox
size="xs"
:model-value="
step.responsiblePersonId.includes(person.id)
"
@click.stop="
selectResponsiblePerson(index, person)
"
/>
<q-avatar class="q-ml-sm" size="md">
<q-img
class="text-center"
:ratio="1"
:src="`${baseUrl}/user/${person.id}/profile-image/${person.selectedImage}`"
>
<template #error>
<div
class="no-padding full-width full-height flex items-center justify-center"
:style="`${person.gender ? 'background: white' : 'background: linear-gradient(135deg,rgba(43, 137, 223, 1) 0%, rgba(230, 51, 81, 1) 100%);'}`"
>
<q-img
v-if="person.gender"
:src="
person.gender === 'male'
? '/no-img-man.png'
: '/no-img-female.png'
"
/>
<q-icon
v-else
size="sm"
name="mdi-account-outline"
style="color: white"
/>
</div>
</template>
</q-img>
</q-avatar>
<div class="column q-pl-md">
<span>
{{
`${optionStore.mapOption(person.namePrefix || '')} ${
$i18n.locale === 'eng'
? person.firstNameEN
: person.firstName
} ${
$i18n.locale === 'eng'
? person.lastNameEN
: person.lastName
}`
}}
</span>
<span class="text-caption app-text-muted">
{{ person.code }}
</span>
</div>
</div>
</q-item>
<span class="text-caption app-text-muted-2 q-px-md">
{{ $t('personnel.MESSENGER') }}
</span>
<q-item
clickable
@click="step.messengerByArea = !step.messengerByArea"
class="column"
>
<div class="row items-center">
<q-checkbox
v-model="step.messengerByArea"
size="xs"
></q-checkbox>
<div class="column q-pl-md">
<span>{{ $t('general.byArea') }}</span>
</div>
</div>
</q-item>
</q-list>
</q-menu>
</q-select>
<!-- RESPONSIBLE-AGENCIES, RESPONSIBLE-INSTITUTION -->
<q-select
behavior="menu"
:bg-color="readonly ? 'transparent' : ''"
:readonly
outlined
dense
v-model="step.responsibleInstitution"
multiple
:options="options"
hide-bottom-space
option-label="label"
option-value="value"
emit-value
:label="
$t('general.select', { msg: $t('general.agencies') })
"
class="col-md-6 col-12"
:hide-dropdown-icon="readonly"
>
<template v-slot:selected-item="scope">
<q-chip
dense
:removable="!readonly"
@remove="scope.removeAtIndex(scope.index)"
>
<span class="text-caption">
{{
scope.opt === 'customer'
? $t('flow.customer')
: scope.opt === 'officer'
? $t('flow.officer')
: optionStore.mapOption(
scope.opt,
'agenciesType',
)
}}
</span>
</q-chip>
</template>
<template v-slot:option></template>
<SelectMenuWithSearch
v-if="!readonly"
:title="
$t('general.select', { msg: $t('general.agencies') })
"
:option="options"
:separator-index="[9]"
width="353.66px"
@search="(v) => optionSearch(v as string)"
@select="
(v) => selectItem(v, step.responsibleInstitution)
"
@before-show="
() => {
objectOptions = [
...(optionStore.globalOption?.agenciesType || []),
{ label: t('flow.customer'), value: 'customer' },
{ label: t('flow.officer'), value: 'officer' },
];
options = objectOptions;
}
"
>
<template #prepend>
<q-separator></q-separator>
</template>
<template
#option="{ opt }"
v-if="step.responsibleInstitution"
>
<q-checkbox
:model-value="
step.responsibleInstitution.some(
(v: string) => v === opt.value,
)
"
class="q-pr-sm"
size="xs"
@click="selectItem(opt, step.responsibleInstitution)"
/>
<span
:class="{
'app-text-info': step.responsibleInstitution.some(
(v: string) => v === opt.value,
),
}"
>
{{ opt.label }}
</span>
</template>
</SelectMenuWithSearch>
</q-select>
</div>
</div>
</section>
</q-expansion-item>
</div>
</template>
</section>
</div>
</template>
<style scoped>
:deep(.responsible-search .q-field__control) {
height: 36px;
font-size: 12px;
}
:deep(
.q-item__section.column.q-item__section--side.justify-center.q-item__section--avatar.q-focusable.relative-position.cursor-pointer
) {
justify-content: start !important;
padding-right: 8px !important;
padding-top: 16px;
min-width: 0px;
}
:deep(i.q-icon.mdi.mdi-chevron-down-circle.q-expansion-item__toggle-icon) {
color: hsl(var(--text-mute));
}
:deep(
i.q-icon.mdi.mdi-chevron-down-circle.q-expansion-item__toggle-icon.q-expansion-item__toggle-icon--rotated
) {
color: var(--brand-1);
}
:deep(
.q-item.q-item-type.row.no-wrap.q-item--dense.q-item--clickable.q-link.cursor-pointer.q-focusable.q-hoverable.expansion-rounded.surface-2
.q-focus-helper
) {
visibility: hidden;
}
:deep(.q-dialog.fullscreen.no-pointer-events.q-dialog--modal) {
visibility: hidden;
}
</style>

View file

@ -1,6 +1,7 @@
<script setup lang="ts">
import { QSelect } from 'quasar';
import useOptionStore from 'src/stores/options';
import { createEditorImageDrop } from 'src/utils/ui';
import { selectFilterOptionRefMod } from 'stores/utils';
import { ref, onMounted, watch } from 'vue';
import { useI18n } from 'vue-i18n';
@ -14,6 +15,7 @@ const process = defineModel<number>('process');
const name = defineModel<string>('name');
const code = defineModel<string>('code');
const expenseType = defineModel<string>('expenseType');
const shared = defineModel<boolean>('shared');
const codeOption = ref<{ id: string; name: string }[]>([]);
@ -66,11 +68,13 @@ watch(
);
},
);
const detailEditorImageDrop = createEditorImageDrop(detail);
</script>
<template>
<div class="row col-12">
<div class="col-12 q-pb-sm text-weight-bold text-body1 row items-center">
<div class="col-12 q-pb-sm row items-center">
<q-icon
flat
size="xs"
@ -79,7 +83,21 @@ watch(
name="mdi-office-building-outline"
style="background-color: var(--surface-3)"
/>
{{ $t(`form.field.basicInformation`) }}
<span class="text-body1 text-weight-bold">
{{ $t(`form.field.basicInformation`) }}
</span>
<section class="q-px-md">
<label :style="{ opacity: readonly ? '.5' : undefined }">
<input
id="input-shared"
class="q-pr-sm"
type="checkbox"
v-model="shared"
:disabled="readonly"
/>
{{ $t('general.shared') }}
</label>
</section>
</div>
<div class="col-12 row q-col-gutter-sm">
@ -92,6 +110,7 @@ watch(
hide-selected
hide-bottom-space
input-debounce="0"
autocomplete="off"
:disable="!readonly && disableCode"
class="col-md-3 col-12"
v-model="code"
@ -101,7 +120,11 @@ watch(
option-value="value"
:dense="dense"
:readonly="readonly"
:options="codeOptions"
:options="
codeOptions.sort((a, b) => {
return String(a.value ?? '').localeCompare(String(b.value ?? ''));
})
"
:label="$t('productService.product.code')"
:hide-dropdown-icon="readonly || disableCode"
:rules="[(val: string) => !!val || $t('form.error.required')]"
@ -141,6 +164,7 @@ watch(
:label="$t('productService.product.processingTime')"
v-model="process"
type="number"
min="0"
>
<template #prepend>
<q-icon
@ -160,6 +184,7 @@ watch(
map-options
hide-selected
hide-bottom-space
autocomplete="off"
input-debounce="0"
class="col-md-3 col-6"
v-model="expenseType"
@ -202,6 +227,7 @@ watch(
@update:model-value="
(v) => (typeof v === 'string' ? (detail = v) : '')
"
@drop="detailEditorImageDrop"
min-height="5rem"
class="q-mt-sm q-mb-xs"
:flat="!readonly"

View file

@ -1,13 +1,11 @@
<script setup lang="ts">
import { QSelect } from 'quasar';
import { getRole } from 'src/services/keycloak';
import { selectFilterOptionRefMod } from 'stores/utils';
import { watch } from 'vue';
import { ref } from 'vue';
import { createEditorImageDrop } from 'src/utils/ui';
import SelectBranch from '../shared/select/SelectBranch.vue';
const remark = defineModel<string>('remark');
const detail = defineModel<string>('detail');
const name = defineModel<string>('name');
const shared = defineModel<boolean>('shared');
const code = defineModel<string>('code');
const serviceCode = defineModel<string>('serviceCode');
@ -15,100 +13,58 @@ const serviceName = defineModel<string>('serviceNameTh');
const serviceDescription = defineModel<string>('serviceDescription');
const registeredBranchId = defineModel<string>('registeredBranchId');
const optionsBranch = defineModel<{ id: string; name: string }[]>(
'optionsBranch',
{ default: [] },
);
defineProps<{
dense?: boolean;
outlined?: boolean;
readonly?: boolean;
readOnlybranchOption?: boolean;
branchReadonly?: boolean;
separator?: boolean;
isType?: boolean;
disableCode?: boolean;
service?: boolean;
}>();
const branchOptions = ref<Record<string, unknown>[]>([]);
let branchFilter = selectFilterOptionRefMod(
optionsBranch,
branchOptions,
'name',
);
watch(
() => optionsBranch.value,
() => {
branchFilter = selectFilterOptionRefMod(
optionsBranch,
branchOptions,
'name',
);
},
);
const detailEditorImageDrop = createEditorImageDrop(detail);
</script>
<template>
<div class="row col-12">
<div class="col-12 q-pb-sm text-weight-bold text-body1">
<div class="col-12 q-pb-sm row items-center">
<q-icon
flat
size="xs"
class="q-pa-sm rounded q-mr-xs"
class="q-pa-sm rounded q-mr-sm"
color="info"
name="mdi-office-building-outline"
style="background-color: var(--surface-3)"
/>
{{ $t(`form.field.basicInformation`) }}
<span class="text-body1 text-weight-bold">
{{ $t(`form.field.basicInformation`) }}
</span>
<section v-if="!service" class="q-px-md">
<label :style="{ opacity: readonly ? '.5' : undefined }">
<input
id="input-shared"
class="q-pr-sm"
type="checkbox"
v-model="shared"
:disabled="readonly"
/>
{{ $t('general.shared') }}
</label>
</section>
</div>
<div v-if="!service" class="col-12 row q-col-gutter-sm">
<q-select
outlined
use-input
fill-input
emit-value
map-options
hide-selected
hide-bottom-space
<SelectBranch
:label="$t('productService.product.registeredBranch')"
class="col-md-12 col-12"
option-value="id"
option-label="name"
v-model="registeredBranchId"
id="input-source-nationality"
for="input-source-nationality"
:disable="!readonly && readOnlybranchOption"
:dense="dense"
:readonly="readonly || readOnlybranchOption"
:options="branchOptions"
:hide-dropdown-icon="readonly || readOnlybranchOption"
:label="$t('productService.service.registeredBranch')"
:rules="[
(val) => {
const roles = getRole() || [];
const isSpecialRole = ['admin', 'system', 'head_of_admin'].some(
(role) => roles.includes(role),
);
return (
isSpecialRole ||
!!val ||
$t('form.error.selectField', {
field: $t('productService.service.registeredBranch'),
})
);
},
]"
@filter="branchFilter"
>
<template v-slot:no-option>
<q-item>
<q-item-section class="text-grey">
{{ $t('general.noData') }}
</q-item-section>
</q-item>
</template>
</q-select>
v-model:value="registeredBranchId"
:readonly
:disabled="!readonly && branchReadonly"
clearable
required
/>
<q-input
for="input-code"
:dense="dense"
@ -135,18 +91,40 @@ watch(
v-model="name"
:rules="[(val: string) => !!val || $t('form.error.required')]"
/>
<q-input
for="input-detail"
:dense="dense"
<q-field
class="full-width"
outlined
for="input-detail"
id="input-detail"
:readonly="readonly"
hide-bottom-space
type="textarea"
class="col-12"
:borderless="readonly"
:label="$t('general.detail')"
:model-value="readonly ? detail || '-' : detail"
@update:model-value="(v) => (typeof v === 'string' ? (detail = v) : '')"
/>
stack-label
dense
>
<q-editor
dense
:model-value="readonly ? detail || '-' : detail || ''"
@update:model-value="
(v) => (typeof v === 'string' ? (detail = v) : '')
"
@drop="detailEditorImageDrop"
min-height="5rem"
class="q-mt-sm q-mb-xs"
:flat="!readonly"
:readonly="readonly"
:toolbar-color="
readonly ? 'disabled' : $q.dark.isActive ? 'white' : ''
"
:toolbar-toggle-color="readonly ? 'disabled' : 'primary'"
style="
cursor: auto;
color: var(--foreground);
border-color: var(--surface-3);
"
:style="`width: ${$q.screen.gt.xs ? '100%' : '63vw'}`"
/>
</q-field>
<q-input
:dense="dense"
outlined
@ -189,21 +167,42 @@ watch(
:rules="[(val: string) => !!val || $t('form.error.required')]"
/>
<q-input
id="input-service-description"
for="input-service-description"
:dense="dense"
<q-field
class="full-width"
outlined
for="input-service-description"
id="input-service-description"
:readonly="readonly"
hide-bottom-space
type="textarea"
class="col-12"
:borderless="readonly"
:label="$t('general.detail')"
:model-value="readonly ? serviceDescription || '-' : serviceDescription"
@update:model-value="
(v) => (typeof v === 'string' ? (serviceDescription = v) : '')
"
/>
stack-label
dense
>
<q-editor
dense
:model-value="
readonly ? serviceDescription || '-' : serviceDescription || ''
"
@update:model-value="
(v) => (typeof v === 'string' ? (serviceDescription = v) : '')
"
@drop="detailEditorImageDrop"
min-height="5rem"
class="q-mt-sm q-mb-xs"
:flat="!readonly"
:readonly="readonly"
:toolbar-color="
readonly ? 'disabled' : $q.dark.isActive ? 'white' : ''
"
:toolbar-toggle-color="readonly ? 'disabled' : 'primary'"
style="
cursor: auto;
color: var(--foreground);
border-color: var(--surface-3);
"
:style="`width: ${$q.screen.gt.xs ? '100%' : '63vw'}`"
/>
</q-field>
</div>
</div>
</template>

View file

@ -0,0 +1,192 @@
<script lang="ts" setup>
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
import SelectMenuWithSearch from '../shared/SelectMenuWithSearch.vue';
const { t } = useI18n();
defineProps<{
readonly?: boolean;
}>();
const attachment = defineModel<string[]>('attachment', { default: [] });
const mapName = (val: string): string => {
const name = objectOptions.find((v) => v.value === val);
return name ? name.label : '';
};
const objectOptions = [
{
label: 'customerEmployee.fileType.passport',
value: 'passport',
},
{
label: 'customerEmployee.fileType.visa',
value: 'visa',
},
{
label: 'customerEmployee.fileType.tm6',
value: 'tm6',
},
{
label: 'customerEmployee.fileType.workPermit',
value: 'workPermit',
},
{
label: 'customerEmployee.fileType.noticeJobEmployment',
value: 'noticeJobEmployment',
},
{
label: 'customerEmployee.fileType.noticeJobEntry',
value: 'noticeJobEntry',
},
{
label: 'customerEmployee.fileType.historyJob',
value: 'historyJob',
},
{
label: 'customerEmployee.fileType.acceptJob',
value: 'acceptJob',
},
];
const options = ref(objectOptions);
function selectAttachment(val: Record<string, unknown>) {
const existIndex = attachment.value.findIndex((p) => p === val.value);
if (existIndex === -1) {
attachment.value.push(val.value as string);
} else {
attachment.value.splice(Number(existIndex), 1);
}
}
function selectAll() {
if (attachment.value.length === options.value.length) {
attachment.value = [];
return;
}
options.value.forEach((opt) => {
const existItem = attachment.value.some((v) => v === opt.value);
if (!existItem) selectAttachment(opt);
});
}
function optionSearch(val: string | null) {
if (val === '') {
options.value = objectOptions;
return;
}
const needle = val ? val.toLowerCase() : '';
options.value = objectOptions.filter(
(v) => t(v.label).toLowerCase().indexOf(needle) > -1,
);
}
</script>
<template>
<div class="row col-12">
<div class="col-12 q-pb-sm row items-center">
<q-icon
flat
size="xs"
class="q-pa-sm rounded q-mr-sm"
color="info"
name="mdi-file-outline"
style="background-color: var(--surface-3)"
/>
<span class="text-body1 text-weight-bold">
{{ $t('general.information', { msg: $t('general.attachment') }) }}
</span>
</div>
<div class="col-12 row q-col-gutter-sm">
<q-select
:readonly
outlined
dense
v-model="attachment"
multiple
:options="options"
use-chips
option-label="label"
option-value="value"
emit-value
:label="$t('general.select', { msg: $t('general.attachment') })"
class="col"
:hide-dropdown-icon="readonly"
>
<template v-slot:selected-item="scope">
<q-chip
:removable="!readonly"
@remove="scope.removeAtIndex(scope.index)"
>
{{ $t(mapName(scope.opt)) }}
</q-chip>
</template>
<template v-slot:option></template>
<SelectMenuWithSearch
v-if="!readonly"
:title="$t('general.select', { msg: $t('general.attachment') })"
:option="options"
width="353.66px"
@search="(v) => optionSearch(v as string)"
@select="(v) => selectAttachment(v)"
>
<template #prepend>
<!-- <q-item
dense
clickable
class="bordered-t flex items-center app-text-muted"
style="padding: 0px 16px"
>
<q-icon name="mdi-plus" class="q-pa-sm q-mr-sm" />
{{ $t('general.add', { text: $t('general.attachment') }) }}
</q-item> -->
<q-item
dense
clickable
class="flex items-center"
style="
padding: 0px 16px;
background-color: hsla(var(--info-bg) / 0.1);
"
@click="selectAll()"
>
<q-checkbox
:model-value="
options.length > 0 && attachment.length === options.length
"
class="q-pr-sm"
size="xs"
@click="selectAll()"
/>
{{ $t('general.selectAll') }}
</q-item>
</template>
<template #option="{ opt }">
<q-checkbox
:model-value="attachment.some((v) => v === opt.value)"
class="q-pr-sm"
size="xs"
@click="selectAttachment(opt)"
/>
<span
:class="{
'app-text-info': attachment.some((v) => v === opt.value),
}"
>
{{ $t(opt.label as string) }}
</span>
</template>
</SelectMenuWithSearch>
</q-select>
</div>
</div>
</template>
<style></style>

View file

@ -1,17 +1,20 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
import { moveItemUp, moveItemDown, deleteItem, dialog } from 'stores/utils';
import { nextTick, ref, watch } from 'vue';
import { WorkflowTemplate } from 'src/stores/workflow-template/types';
import { ServiceCreate, WorkItems } from 'stores/product-service/types';
import NoData from 'components/NoData.vue';
import WorkManagementComponent from './WorkManagementComponent.vue';
import AddButton from '../button/AddButton.vue';
import { ServiceCreate, WorkItems } from 'stores/product-service/types';
import TreeView from '../shared/TreeView.vue';
import { nextTick, ref, watch } from 'vue';
const { t } = useI18n();
const workItems = defineModel<WorkItems[]>('workItems', { default: [] });
const workflow = defineModel<WorkflowTemplate>('workflow');
const props = defineProps<{
service?: ServiceCreate;
@ -20,6 +23,7 @@ const props = defineProps<{
readonly?: boolean;
separator?: boolean;
treeView?: boolean;
installments?: number;
priceDisplay?: {
price: boolean;
@ -74,7 +78,22 @@ async function addWork() {
workItems.value.push({
id: '',
name: '',
attributes: { additional: [], showTotalPrice: false },
attributes: {
workflowStep: workflow.value?.step
? JSON.parse(
JSON.stringify(
workflow.value.step.map((step) => ({
name: step.name,
attributes: step.attributes,
productsId: [],
})),
),
)
: [],
additional: [],
showTotalPrice: false,
workflowId: workflow.value ? workflow.value.id : '',
},
product: [],
});
await nextTick();
@ -170,15 +189,24 @@ watch(
:key="work.id"
:index="index"
:length="workItems.length"
:workIndex="index"
:readonly="readonly"
:priceDisplay="priceDisplay"
:work-index="index"
:readonly
:price-display
:installments
v-model:work-name="workItems[index].name"
v-model:product-items="work.product"
v-model:attributes="work.attributes"
@add-product="$emit('addProduct', index)"
@move-work-up="moveItemUp(workItems, index)"
@move-work-down="moveItemDown(workItems, index)"
@move-work-up="
() => {
moveItemUp(workItems, index);
}
"
@move-work-down="
() => {
moveItemDown(workItems, index);
}
"
@delete-work="confirmDelete(workItems, index)"
@move-product-up="moveItemUp"
@move-product-down="moveItemDown"

View file

@ -1,6 +1,8 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { commaInput } from 'stores/utils';
import { formatNumberDecimal, commaInput } from 'stores/utils';
import { QTableProps } from 'quasar';
import { calculatePrice } from 'src/utils/arithmetic';
const serviceCharge = defineModel<number>('serviceCharge');
const agentPrice = defineModel<number>('agentPrice');
@ -8,9 +10,67 @@ const price = defineModel<number>('price');
const vatIncluded = defineModel<boolean>('vatIncluded');
const calcVat = defineModel<boolean>('calcVat');
const price4Show = ref('');
const agentPrice4Show = ref('');
const serviceCharge4Show = ref('');
const price4Show = ref<string>(commaInput(price.value?.toString() || '0'));
const agentPrice4Show = ref<string>(
commaInput(agentPrice.value?.toString() || '0'),
);
const serviceCharge4Show = ref<string>(
commaInput(serviceCharge.value?.toString() || '0'),
);
const column = [
{
name: 'label',
align: 'center',
label: 'productService.product.priceInformation',
field: 'label',
},
{
name: 'pricePerUnit',
align: 'center',
label: 'quotation.pricePerUnit',
field: 'pricePerUnit',
},
{
name: 'beforeVat',
align: 'right',
label: 'quotation.priceBeforeVat',
field: 'beforeVat',
},
{
name: 'vat',
align: 'right',
label: 'general.vat',
field: 'vat',
},
{
name: 'total',
align: 'right',
label: 'quotation.sumPrice',
field: 'total',
},
] as const satisfies QTableProps['columns'];
const row = [
{
label: 'productService.product.salePrice',
beforeVat: 0,
vat: 0,
total: 0,
},
{
label: 'productService.product.agentPrice',
beforeVat: 0,
vat: 0,
total: 0,
},
{
label: 'productService.product.processingPrice',
beforeVat: 0,
vat: 0,
total: 0,
},
] as const satisfies QTableProps['rows'];
watch(calcVat, () => {
if (calcVat.value === false) vatIncluded.value = false;
@ -101,81 +161,211 @@ withDefaults(
</span>
</div>
</div>
<div class="col-12 row q-col-gutter-sm">
<q-input
id="input-price"
for="input-price"
v-if="priceDisplay?.price"
:dense="dense"
outlined
:readonly="readonly"
:borderless="readonly"
hide-bottom-space
class="col-4"
:label="$t('productService.product.salePrice')"
:model-value="commaInput(price?.toString() || '0')"
@update:model-value="
(v) => {
if (typeof v === 'string') price4Show = commaInput(v);
const x = parseFloat(
price4Show && typeof price4Show === 'string'
? price4Show.replace(/,/g, '')
: '',
);
price = x;
}
"
/>
<q-input
id="input-agent-price"
for="input-agent-price"
v-if="priceDisplay?.agentPrice"
:dense="dense"
outlined
:readonly="readonly"
:borderless="readonly"
hide-bottom-space
class="col-4"
:label="$t('productService.product.agentPrice')"
:model-value="commaInput(agentPrice?.toString() || '0')"
@update:model-value="
(v) => {
if (typeof v === 'string') agentPrice4Show = commaInput(v);
const x = parseFloat(
agentPrice4Show && typeof agentPrice4Show === 'string'
? agentPrice4Show.replace(/,/g, '')
: '',
);
agentPrice = x;
}
"
/>
<div class="col-12">
<q-table
:columns="column"
:rows="row"
:rows-per-page-options="[0]"
bordered
flat
hide-pagination
class="full-width"
:no-data-label="$t('general.noDataTable')"
>
<template v-slot:header="props">
<q-tr
style="background-color: hsla(var(--info-bg) / 0.07)"
:props="props"
>
<q-th v-for="col in props.cols" :key="col.name" :props="props">
{{ col.label && $t(col.label) }}
</q-th>
</q-tr>
</template>
<q-input
id="input-service-charge"
for="input-service-charge"
v-if="priceDisplay?.serviceCharge"
:dense="dense"
outlined
:readonly="readonly"
:borderless="readonly"
hide-bottom-space
class="col-4"
:label="$t('productService.product.processingPrice')"
:model-value="commaInput(serviceCharge?.toString() || '0')"
@update:model-value="
(v) => {
if (typeof v === 'string') serviceCharge4Show = commaInput(v);
const x = parseFloat(
serviceCharge4Show && typeof serviceCharge4Show === 'string'
? serviceCharge4Show.replace(/,/g, '')
: '',
);
serviceCharge = x;
}
"
/>
<template v-slot:body="props">
<q-tr>
<q-td>{{ $t(props.row.label) }}</q-td>
<q-td class="text-right" style="width: 15%">
<q-input
v-if="priceDisplay?.price && props.rowIndex === 0"
id="input-price"
for="input-price"
:dense="dense"
outlined
:readonly="readonly"
:borderless="readonly"
hide-bottom-space
input-class="text-right"
:model-value="price4Show"
@blur="
() => {
price = Number(price4Show.replace(/,/g, ''));
if (price % 1 === 0) {
const [, dec] = price4Show.split('.');
if (!dec) {
price4Show += '.00';
}
}
}
"
@update:model-value="
(v) => {
price4Show = commaInput(v?.toString() || '0', 'string');
}
"
/>
<q-input
v-if="priceDisplay?.agentPrice && props.rowIndex === 1"
id="input-agent-price"
for="input-agent-price"
:dense="dense"
outlined
:readonly="readonly"
:borderless="readonly"
hide-bottom-space
input-class="text-right"
:model-value="agentPrice4Show"
@blur="
() => {
agentPrice = Number(agentPrice4Show.replace(/,/g, ''));
if (agentPrice % 1 === 0) {
const [, dec] = agentPrice4Show.split('.');
if (!dec) {
agentPrice4Show += '.00';
}
}
}
"
@update:model-value="
(v) => {
agentPrice4Show = commaInput(
v?.toString() || '0',
'string',
);
}
"
/>
<q-input
v-if="priceDisplay?.serviceCharge && props.rowIndex === 2"
id="input-service-charge"
for="input-service-charge"
:dense="dense"
outlined
:readonly="readonly"
:borderless="readonly"
input-class="text-right"
hide-bottom-space
:model-value="serviceCharge4Show"
@blur="
() => {
serviceCharge = Number(
serviceCharge4Show.replace(/,/g, ''),
);
if (serviceCharge % 1 === 0) {
const [, dec] = serviceCharge4Show.split('.');
if (!dec) {
serviceCharge4Show += '.00';
}
}
}
"
@update:model-value="
(v) => {
serviceCharge4Show = commaInput(
v?.toString() || '0',
'string',
);
}
"
/>
</q-td>
<q-td class="text-right" style="width: 15%">
{{
formatNumberDecimal(
calculatePrice({
output: 'beforeVat',
vatIncluded: vatIncluded,
price:
(props.rowIndex === 0
? price
: props.rowIndex === 1
? agentPrice
: serviceCharge) || 0,
}),
2,
)
}}
</q-td>
<q-td class="text-right" style="width: 15%">
{{
formatNumberDecimal(
calculatePrice({
output: 'vat',
calcVat: calcVat,
price: Number(
formatNumberDecimal(
calculatePrice({
output: 'beforeVat',
vatIncluded: vatIncluded,
price:
(props.rowIndex === 0
? price
: props.rowIndex === 1
? agentPrice
: serviceCharge) || 0,
}),
2,
).replaceAll(',', ''),
),
}),
2,
)
}}
</q-td>
<q-td class="text-right" style="width: 15%">
<span
class="text-weight-bold"
:class="{
'tags-color-orange': props.rowIndex === 0,
'tags-color-purple': props.rowIndex === 1,
'tags-color-pink': props.rowIndex === 2,
dark: $q.dark.isActive,
}"
>
{{
formatNumberDecimal(
calculatePrice({
output: 'total',
vat: 0.03,
price:
(props.rowIndex === 0
? price
: props.rowIndex === 1
? agentPrice
: serviceCharge) || 0,
}) +
(!vatIncluded
? calculatePrice({
output: 'vat',
calcVat: calcVat,
price:
(props.rowIndex === 0
? price
: props.rowIndex === 1
? agentPrice
: serviceCharge) || 0,
})
: 0),
2,
)
}}
</span>
</q-td>
</q-tr>
</template>
</q-table>
</div>
</div>
</template>
@ -190,4 +380,24 @@ withDefaults(
background-color: var(--surface-1);
}
}
.tags-color-orange {
color: var(--orange-5);
}
.dark .tags-color-orange {
color: var(--orange-6);
}
.tags-color-purple {
color: var(--violet-11);
}
.dark .tags-color-purple {
color: var(--violet-10);
}
.tags-color-pink {
color: var(--pink-6);
}
</style>

View file

@ -65,7 +65,10 @@ withDefaults(
</div>
<div class="q-px-md" :class="{ 'disabled-card': isDisabled }">
<div class="text-subtitle1 text-bold">{{ title }}</div>
<div class="text-subtitle1 text-bold ellipsis-2-lines">
{{ title }}
<q-tooltip>{{ title }}</q-tooltip>
</div>
<div class="text-subtitle3 app-text-muted">{{ subtitle }}</div>
<div class="row q-gutter-md q-mt-sm">
<div

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,388 @@
<script setup lang="ts">
import { ref } from 'vue';
import { QTableProps } from 'quasar';
import { Product } from 'stores/product-service/types';
import KebabAction from 'src/components/shared/KebabAction.vue';
import { MainButton } from 'components/button';
import useOptionStore from 'stores/options';
import { formatNumberDecimal } from 'stores/utils';
import { dateFormat } from 'src/utils/datetime';
const optionStore = useOptionStore();
const baseUrl = ref<string>(import.meta.env.VITE_API_BASE_URL);
const selectedItem = defineModel<Product[]>('selectedItem');
const isSort = ref<boolean>(false);
const props = withDefaults(
defineProps<{
row: QTableProps['rows'];
column: QTableProps['columns'];
grid?: boolean;
fieldSelected?: string[];
currentPage?: number;
pageSize?: number;
useKebabAction?: boolean;
useSortAction?: boolean;
}>(),
{
row: () => [],
column: () => [],
grid: false,
fieldSelected: () => [],
currentPage: 1,
pageSize: 1,
useKebabAction: false,
},
);
defineEmits<{
(e: 'view'): void;
(e: 'edit'): void;
(e: 'delete'): void;
(e: 'changeStatus'): void;
(e: 'select', data: any): void;
(e: 'sort', isSort: boolean): void;
}>();
</script>
<template>
<q-table
bordered
:rows="row"
:columns="column"
:rows-per-page-options="[0]"
:grid="grid"
v-model:selected="selectedItem"
row-key="id"
@update:selected="(v) => $emit('select', v)"
card-container-class="row full-width q-col-gutter-md"
hide-pagination
selection="multiple"
>
<template v-slot:header="props">
<q-tr
style="background-color: hsla(var(--info-bg) / 0.07)"
:props="props"
>
<q-th auto-width>
<!-- <MainButton
icon="mdi-sort"
color="0 0% 0%"
@click="
() => {
$emit('sort', (isSort = !isSort));
}
"
/> -->
</q-th>
<template v-for="col in props.cols" :key="col.name">
<q-th>
{{ $t(col.label) }}
</q-th>
</template>
<q-th v-if="!!useKebabAction" auto-width />
</q-tr>
</template>
<template v-slot:body="props">
<q-tr
:style="
props.rowIndex % 2 !== 0
? $q.dark.isActive
? 'background: hsl(var(--gray-11-hsl)/0.2)'
: `background: #f9fafc`
: ''
"
:class="{
'app-text-muted': props.row.status === 'INACTIVE',
}"
class="cursor-pointer"
:props="props"
@click.stop="$emit('select', props.row)"
>
<q-td>
<q-checkbox
:model-value="props.selected"
@click.stop="$emit('select', props.row)"
/>
</q-td>
<q-td
class="text-center"
v-if="fieldSelected.includes('branchLabelNo')"
>
{{ (currentPage - 1) * pageSize + props.rowIndex + 1 }}
</q-td>
<q-td v-if="fieldSelected.includes('productName')">
<div class="row items-center no-wrap">
<div
:class="{
'status-active': props.row.status !== 'INACTIVE',
'status-inactive': props.row.status === 'INACTIVE',
}"
style="width: 50px; display: flex; margin-bottom: var(--size-2)"
>
<div class="table__icon" :class="`icon-color-green`">
<q-avatar size="md">
<q-img
class="text-center"
:ratio="1"
:src="`${baseUrl}/product/${props.row.id}/image/${props.row.selectedImage}`"
>
<template #error>
<q-icon
size="sm"
name="mdi-shopping-outline"
style="top: 10%"
:style="`color: var(teal-10)`"
/>
</template>
</q-img>
</q-avatar>
</div>
</div>
<div class="column">
<div class="ellipsis" style="max-width: 20vw">
{{ props.row.name }}
<q-tooltip anchor="bottom left" self="center left" :delay="300">
{{ props.row.name }}
</q-tooltip>
</div>
<div class="app-text-muted">
{{ props.row.code }}
</div>
</div>
</div>
</q-td>
<q-td
class="ellipsis"
style="max-width: 150px"
v-if="fieldSelected.includes('productDetail')"
>
{{ props.row.detail.replace(/<\/?[^>]+(>|$)/g, '') || '-' }}
</q-td>
<q-td v-if="fieldSelected.includes('productExpenseType')">
{{ optionStore.mapOption(props.row.expenseType) }}
</q-td>
<q-td v-if="fieldSelected.includes('productProcessingTime')">
{{ props.row.process }}
</q-td>
<q-td v-if="fieldSelected.includes('productVat')">
{{ $t('productService.product.vatIncluded') }}
</q-td>
<q-td v-if="fieldSelected.includes('priceInformation')">
<div
class="row full-width q-gutter-x-md no-wrap items-center text-right"
>
<div
class="tags tags-color-orange col column ellipsis-2-lines"
:class="{
disable: props.row.status === 'INACTIVE',
}"
style="min-width: 50px"
>
<div class="col app-text-muted-2 text-caption">
{{ $t('productService.product.salePrice') }}
</div>
<div class="col text-weight-bold">
฿{{ formatNumberDecimal(props.row.price || 0, 2) }}
</div>
</div>
<div
class="tags tags-color-purple col column ellipsis-2-lines"
:class="{
disable: props.row.status === 'INACTIVE',
}"
style="min-width: 50px"
>
<div class="col app-text-muted-2 text-caption">
{{ $t('productService.product.agentPrice') }}
</div>
<div class="col text-weight-bold">
฿{{ formatNumberDecimal(props.row.agentPrice || 0, 2) }}
</div>
</div>
<div
class="tags tags-color-pink col column ellipsis-2-lines"
:class="{
disable: props.row.status === 'INACTIVE',
}"
style="min-width: 50px"
>
<div class="col app-text-muted-2 text-caption">
{{ $t('productService.product.processingPrice') }}
</div>
<div class="col">
฿{{ formatNumberDecimal(props.row.serviceCharge || 0, 2) }}
</div>
</div>
</div>
</q-td>
<q-td v-if="fieldSelected.includes('createdAt')">
{{ dateFormat(props.row.createdAt) }}
</q-td>
<q-td v-if="!!useKebabAction">
<q-btn
icon="mdi-eye-outline"
:id="`btn-eye-${props.row.name}`"
size="sm"
dense
round
flat
@click.stop="$emit('view')"
/>
<KebabAction
:status="props.row.status"
:id-name="props.row.name"
@view="$emit('view')"
@edit="$emit('edit')"
@delete="$emit('delete')"
@change-status="$emit('changeStatus')"
/>
</q-td>
</q-tr>
</template>
<template v-slot:item="{ row }">
<div class="col-3"><slot name="grid" :row="row" /></div>
</template>
</q-table>
</template>
<style scoped>
.status-active {
--_branch-status-color: var(--green-6-hsl);
}
.status-inactive {
--_branch-status-color: var(--stone-5-hsl);
--_branch-badge-bg: var(--stone-5-hsl);
filter: grayscale(0.5);
opacity: 0.5;
}
.icon-color-purple {
--_color: var(--violet-11-hsl);
}
.icon-color-pink {
--_color: var(--pink-6-hsl);
}
.icon-color-orange {
--_color: var(--orange-5-hsl);
}
.icon-color-green {
--_color: var(--teal-10-hsl);
}
.dark .icon-color-purple {
--_color: var(--violet-10-hsl);
}
.dark .icon-color-green {
--_color: var(--teal-8-hsl);
}
.dark .icon-color-orange {
--_color: var(--orange-6-hsl);
}
.tags-color-green {
--_color-tag: var(--teal-10-hsl);
}
.dark .tags-color-green {
--_color-tag: var(--teal-8-hsl);
}
.tags-color-orange {
--_color-tag: var(--orange-5-hsl);
}
.dark .tags-color-orange {
--_color-tag: var(--orange-6-hsl);
}
.tags-color-purple {
--_color-tag: var(--violet-11-hsl);
}
.dark .tags-color-purple {
--_color-tag: var(--violet-10-hsl);
}
.tags-color-pink {
--_color-tag: var(--pink-6-hsl);
}
.table__icon {
background-color: hsla(var(--_color) / 0.15);
color: hsla(var(--_color) / 1);
border-radius: 50%;
position: relative;
transform: rotate(45deg);
&::after {
content: ' ';
display: block;
block-size: 0.5rem;
aspect-ratio: 1;
position: absolute;
border-radius: 50%;
right: -0.1rem;
top: calc(50% - 0.25rem);
bottom: calc(50% - 0.25rem);
background-color: hsla(var(--_branch-status-color) / 1);
}
&:deep(.q-icon) {
transform: rotate(-45deg);
color: hsla(var(--_branch-card-bg) / 1);
}
&:deep(.q-img) {
transform: rotate(-45deg);
&:deep(.q-icon) {
transform: rotate(0deg);
}
}
}
.tags {
display: inline-block;
color: hsla(var(--_color-tag) / 1);
background: hsla(var(--_color-tag) / 0.075);
border-radius: var(--radius-2);
padding-inline: var(--size-2);
&.disable {
filter: grayscale(100%);
opacity: 80%;
}
}
* :deep(.q-icon.mdi-play) {
display: none;
}
.product-form-active {
background-color: hsla(var(--info-bg) / 0.2);
color: hsl(var(--info-bg));
font-weight: 600;
}
:deep(.split-pay .q-field__control) {
height: 23px;
}
</style>

View file

@ -11,21 +11,16 @@ withDefaults(
defineProps<{
data?: any;
title?: string;
dense?: boolean;
outlined?: boolean;
readonly?: boolean;
separator?: boolean;
typeProduct?: string;
isAddProduct?: boolean;
action?: boolean;
index?: number;
isDisabled?: boolean;
noTimeImg?: boolean;
priceDisplay?: {
price: boolean;
agentPrice: boolean;
@ -36,6 +31,14 @@ withDefaults(
action: false,
},
);
defineEmits<{
(e: 'select', data: any): void;
(e: 'menuViewDetail'): void;
(e: 'menuEdit'): void;
(e: 'menuDelete'): void;
(e: 'toggleStatus', id: string): void;
}>();
</script>
<template>
@ -97,8 +100,8 @@ withDefaults(
<div v-if="data?.type === 'product'" class="flex full-width">
<div
class="row full-width text-right"
style="font-size: 10px; color: hsl(var(--stone-4-hsl))"
class="row full-width text-right app-text-muted"
style="font-size: 10px"
>
<div class="col" v-if="priceDisplay?.price">
{{ $t('productService.product.salePrice') }}

View file

@ -1,26 +1,34 @@
<script lang="ts" setup>
import { Icon } from '@iconify/vue';
import { formatNumberDecimal } from 'stores/utils';
import useProductServiceStore from 'stores/product-service';
import useOptionStore from 'stores/options';
import { Attributes, Product } from 'stores/product-service/types';
import { storeToRefs } from 'pinia';
import { ref, watch } from 'vue';
import { Icon } from '@iconify/vue';
import { onMounted, ref, watch } from 'vue';
import useOptionStore from 'stores/options';
import useProductServiceStore from 'stores/product-service';
import { formatNumberDecimal } from 'stores/utils';
import { useWorkflowTemplate } from 'src/stores/workflow-template';
import { Attributes, Product } from 'stores/product-service/types';
import NoData from '../NoData.vue';
import { AddButton } from '../button';
const baseUrl = ref<string>(import.meta.env.VITE_API_BASE_URL);
const productServiceStore = useProductServiceStore();
const optionStore = useOptionStore();
const workflowStore = useWorkflowTemplate();
const { fetchListOfWork } = productServiceStore;
const { workNameItems } = storeToRefs(productServiceStore);
withDefaults(
const { data: workflowData } = storeToRefs(workflowStore);
const props = withDefaults(
defineProps<{
workIndex: number;
length: number;
index: number;
readonly?: boolean;
installments?: number;
priceDisplay?: {
price: boolean;
@ -61,14 +69,41 @@ defineEmits<{
(e: 'workProperties'): void;
}>();
async function fetchWorkflowOption(val?: string) {
const res = await workflowStore.getWorkflowTemplateList({
query: val,
pageSize: 30,
});
if (res) workflowData.value = res.result;
}
function toggleCheckProductInStep(id: string, stepIndex: number) {
const index = attributes.value.workflowStep[stepIndex].productsId.indexOf(id);
if (!attributes.value.workflowStep[stepIndex].productsId.includes(id)) {
attributes.value.workflowStep[stepIndex].productsId.push(id);
} else {
attributes.value.workflowStep[stepIndex].productsId.splice(index, 1);
}
}
onMounted(async () => {
if (props.workIndex === 0) {
await fetchWorkflowOption();
}
});
watch(
() => workNameItems.value,
(c, o) => {
const list = c.map((v: { name: string }) => v.name);
const oldList = o.map((v: { name: string }) => v.name);
const index = oldList.indexOf(workName.value);
const index = oldList.indexOf(workName.value || '');
if (list[index] !== oldList[index] && !list.includes(workName.value)) {
if (
list[index] !== oldList[index] &&
!list.includes(workName.value || '')
) {
if (list.length - 1 === index - 1) workName.value = list[index - 1];
else workName.value = list[index];
}
@ -84,7 +119,7 @@ watch(
switch-toggle-side
default-opened
expand-icon="mdi-chevron-down-circle"
header-class="surface-2 expansion-rounded"
header-class="expansion-rounded"
header-style="border-top-left-radius: var(--radius-2); border-top-right-radius: var(--radius-2)"
>
<template v-slot:header>
@ -185,6 +220,27 @@ watch(
</q-item>
</q-menu>
</div>
<!-- <SelectInput
:readonly
incremental
:model-value="mapFlowName(attributes.workflowId)"
id="select-workflow-name"
for="select-workflow-name"
class="col"
option-label="name"
:option="workflowData"
:placeholder="$t('productService.service.workName')"
@update:model-value="(val: WorkflowTemplate) => selectFlow(val)"
@filter="(val: string, update) => filter(val, update)"
>
<template #prepend>
<span class="text-body2" style="color: var(--foreground)">
{{
$t('productService.service.workNo', { msg: workIndex + 1 })
}}:
</span>
</template>
</SelectInput> -->
<q-btn
v-if="!readonly"
id="btn-delete-work"
@ -203,60 +259,14 @@ watch(
</div>
</template>
<div class="surface-2">
<!-- properties -->
<div class="bordered-t">
<div
class="q-py-xs text-weight-medium row justify-between items-center q-px-md"
style="background-color: hsla(var(--info-bg) / 0.1)"
>
<span>
{{ $t('productService.service.propertiesInWork') }}
{{ workIndex + 1 }}
</span>
<q-btn
v-if="!readonly"
id="btn-add-work-product"
class="text-capitalize"
flat
dense
padding="0"
style="color: hsl(var(--info-bg))"
@click.stop="$emit('workProperties')"
>
<Icon
icon="basil:settings-adjust-solid"
width="24px"
class="q-mr-sm"
style="color: hsl(var(--info-bg))"
/>
<span v-if="$q.screen.gt.xs">
{{ $t('productService.service.properties') }}
</span>
</q-btn>
</div>
<div class="q-py-md q-px-md full-width">
<div
v-if="attributes.additional.length > 0"
class="row items-center full-width surface-1 q-pb-md q-pt-sm q-px-sm q-gutter-sm scroll"
:style="$q.screen.xs ? 'max-height: 100px' : ''"
>
<div
v-for="(p, index) in attributes.additional"
:key="index"
class="bordered q-px-sm surface-3"
style="border-radius: 6px"
>
{{ optionStore.mapOption(p.fieldName ?? '') }}
</div>
</div>
<div v-else class="app-text-muted">
{{ $t('productService.service.noProperties') }}
</div>
</div>
</div>
<section
v-if="!workName && productItems.length === 0"
class="surface-2 row items-center justify-center q-py-sm"
>
<NoData />
</section>
<section v-else class="surface-2">
<!-- product -->
<div class="bordered-t">
<div
@ -276,19 +286,11 @@ watch(
:disable="readonly"
/>
</span>
<q-btn
<AddButton
v-if="!readonly"
icon-only
id="btn-add-work-product"
for="btn-add-work-product"
flat
dense
icon="mdi-plus"
class="text-capitalize"
:label="
$q.screen.gt.xs ? $t('productService.product.addTitle') : ''
"
padding="0"
style="color: hsl(var(--info-bg))"
@click.stop="$emit('addProduct')"
/>
</div>
@ -297,15 +299,17 @@ watch(
v-if="productItems.length > 0"
class="q-py-md q-px-md full-width q-gutter-y-sm"
>
<div
<section
v-for="(product, index) in productItems"
:key="product.id"
class="full-width row items-center justify-between"
>
<div
class="row col items-center justify-between full-width surface-1 q-py-md q-px-sm"
class="row col items-center justify-between full-width surface-1 q-px-sm q-py-xs"
style="min-height: 70px"
>
<div
<!-- product detail -->
<section
class="row items-center col-md col-12 no-wrap"
v-if="productItems"
>
@ -316,6 +320,7 @@ watch(
dense
flat
round
size="sm"
:disable="index === 0"
style="color: hsl(var(--text-mute-2))"
@click.stop="$emit('moveProductUp', productItems, index)"
@ -328,27 +333,27 @@ watch(
dense
flat
round
class="q-mx-sm"
size="sm"
:disable="index === productItems.length - 1"
style="color: hsl(var(--text-mute-2))"
@click.stop="$emit('moveProductDown', productItems, index)"
/>
<q-avatar
size="md"
:class="$q.screen.gt.xs ? 'q-mx-lg' : 'q-mr-lg'"
size="sm"
class="q-mx-sm"
style="background-color: var(--surface-tab)"
>
{{ index + 1 }}
</q-avatar>
<div class="row no-wrap">
<div class="col row no-wrap items-center">
<div
v-if="$q.screen.gt.xs"
class="bordered q-mx-md col-3 image-box"
>
<q-img
:src="`${baseUrl}/product/${product?.id}/image`"
:src="`${baseUrl}/product/${product.id}/image/${product.selectedImage}`"
style="object-fit: cover; width: 100%; height: 100%"
>
<template #error>
@ -360,35 +365,30 @@ watch(
</template>
</q-img>
</div>
<div class="column col justify-between">
<article class="column col full-width justify-between">
<span
class="text-weight-bold ellipsis-2-lines"
:style="`max-width: ${$q.screen.gt.sm ? '25vw' : '20vw'}`"
class="text-weight-medium ellipsis-2-lines full-width"
>
{{ product.name }}
<q-tooltip>
{{ product.name }}
</q-tooltip>
</span>
<div
class="bordered q-px-xs ellipsis"
style="border-radius: 6px; max-width: 100px"
>
{{ product.code }}
</div>
</div>
</div>
</div>
<div class="text-caption">
<span class="bordered q-px-xs rounded q-mr-sm">
{{ product.code }}
</span>
<div
class="row justify-end text-right col-md-6 col-12"
:class="$q.screen.xs ? 'q-mt-sm text-caption' : 'q-pr-sm'"
>
<span
class="col-12 row"
:class="{ 'q-col-gutter-md': $q.screen.gt.xs }"
style="color: var(--teal-9)"
>
<q-icon name="mdi-clock-outline" />
{{ product.process }} {{ $t('general.day') }}
</div>
</article>
</div>
</section>
<!-- product price -->
<div class="row justify-end text-right col-md col-12">
<span class="col-12 row" style="color: var(--teal-9)">
<span
v-if="priceDisplay?.price"
class="col ellipsis price-orange text-weight-bold"
@ -437,13 +437,27 @@ watch(
฿{{ formatNumberDecimal(product.serviceCharge, 2) }}
</q-tooltip>
</span>
</span>
<span class="col-9 q-mt-sm text-caption app-text-muted-2">
{{ $t('productService.product.processingTime') }}
</span>
<span class="col-3 q-mt-sm text-caption app-text-muted-2">
{{ product.process }} {{ $t('general.day') }}
<span class="col ellipsis column text-weight-medium">
<div class="text-caption app-text-muted-2">
{{ $t('productService.service.InstallmentsNo') }}
</div>
{{ !readonly ? '' : product.installmentNo }}
<span class="row justify-end">
<q-input
v-if="!readonly && $q.screen.gt.xs"
outlined
:max="installments"
input-class="text-right no-padding"
for="input-bankbook"
hide-bottom-space
class="installment-no col-10"
dense
type="number"
v-model="product.installmentNo"
min="1"
/>
</span>
</span>
</span>
</div>
</div>
@ -463,18 +477,181 @@ watch(
>
<q-tooltip>{{ $t('general.delete') }}</q-tooltip>
</q-btn>
</div>
</section>
</div>
<div v-else class="app-text-muted q-py-md q-px-lg">
<div v-else class="app-text-muted q-py-md q-px-md">
{{ $t('productService.product.noProduct') }}
</div>
</div>
</div>
<!-- properties -->
<div class="bordered-t">
<q-expansion-item
default-opened
switch-toggle-side
header-style="background-color: hsla(var(--info-bg) / 0.1); padding: 4px 16px; min-height: 36.08px"
header-class="row items-center q-px-md"
expand-icon-class="no-padding"
dense
>
<template #header>
<div
class="text-weight-medium row justify-between items-center full-width"
>
<span>
{{ $t('flow.processStep') }}
</span>
<q-btn
v-if="!readonly"
id="btn-add-work-product"
class="text-capitalize rounded"
flat
dense
padding="4px 8px"
style="color: hsl(var(--info-bg))"
@click.stop="$emit('workProperties')"
>
<Icon
icon="basil:settings-adjust-solid"
width="20.08px"
style="color: hsl(var(--info-bg))"
/>
</q-btn>
</div>
</template>
<div class="q-py-md q-px-md full-width column">
<span class="app-text-muted">
{{
!attributes.hasOwnProperty('workflowStep')
? $t('general.no', { msg: $t('flow.title') })
: !attributes.workflowId
? $t('general.no', { msg: $t('flow.title') })
: attributes.workflowStep?.length === 0
? $t('flow.noProcessStep')
: attributes.workflowStep?.every(
(s) => !s.attributes.properties?.length,
)
? $t('productService.service.noPropertiesYet')
: ''
}}
</span>
<template
v-for="(step, stepIndex) in attributes.workflowStep"
:key="stepIndex"
>
<span
v-if="
attributes.workflowStep[stepIndex].attributes.properties
.length > 0
"
>
<q-icon name="mdi-circle-medium" />
{{ $t('flow.stepNo', { msg: stepIndex + 1 }) }}:
{{ step.name }}
<!-- step att -->
<section
class="col scroll q-pa-sm flex items-center surface-1 rounded"
>
<div
v-if="
attributes.workflowStep[stepIndex].attributes.properties
.length > 0
"
class="row q-gutter-sm"
>
<span
v-for="(att, i) in step.attributes.properties"
:key="i"
class="surface-2 bordered rounded q-px-xs"
>
{{ optionStore.mapOption(att.fieldName ?? '') }}
</span>
</div>
<div v-else class="app-text-muted-2">
{{ $t('productService.service.noProperties') }}
</div>
</section>
<!-- step product -->
<section
class="q-pt-sm q-pl-lg column"
:class="{
'q-pb-sm':
stepIndex !== attributes.workflowStep.length - 1,
}"
>
<span class="app-text-muted-2 text-caption">
{{
$t('general.select', {
msg: $t('productService.product.title'),
})
}}
</span>
<div
v-if="productItems.length > 0"
class="surface-1 rounded q-pa-xs"
>
<div v-for="product in productItems" :key="product.id">
<q-checkbox
v-if="attributes.workflowStep[stepIndex].productsId"
:disable="readonly"
:model-value="
attributes.workflowStep[
stepIndex
].productsId.includes(product.id)
"
@click="
() => {
if (readonly) return;
toggleCheckProductInStep(product.id, stepIndex);
}
"
size="xs"
/>
{{ product.name }}
</div>
</div>
<span
v-else
class="app-text-muted-2 surface-1 rounded q-pa-xs"
>
{{ $t('productService.product.noProduct') }}
</span>
</section>
</span>
</template>
</div>
</q-expansion-item>
<!-- <div class="q-py-md q-px-md full-width">
<div
v-if="attributes.additional.length > 0"
class="row items-center full-width surface-1 q-pb-md q-pt-sm q-px-sm q-gutter-sm scroll"
:style="$q.screen.xs ? 'max-height: 100px' : ''"
>
<div
v-for="(p, index) in attributes.additional"
:key="index"
class="bordered q-px-sm surface-3"
style="border-radius: 6px"
>
{{ optionStore.mapOption(p.fieldName ?? '') }}
</div>
</div>
<div v-else class="app-text-muted">
{{ $t('productService.service.noProperties') }}
</div>
</div> -->
</div>
</section>
</q-expansion-item>
<div class="q-py-sm q-px-md bordered-t row items-center justify-between">
<div>
{{ $t('productService.service.totalProductWork') }}
<span class="app-text-muted-2">
<!-- {{ mapFlowName(attributes.workflowId) }} -->
{{ workName }}
</span>
</div>
@ -487,8 +664,8 @@ watch(
<style lang="scss" scoped>
.image-box {
height: 70px;
width: 70px;
height: 45px;
width: 45px;
border-color: var(--teal-9);
border-radius: 10px;
background-color: var(--surface-3);
@ -525,4 +702,18 @@ watch(
.price-pink {
color: var(--pink-6);
}
:deep(i.q-icon.mdi.mdi-chevron-down-circle.q-expansion-item__toggle-icon) {
color: hsl(var(--text-mute));
}
:deep(
i.q-icon.mdi.mdi-chevron-down-circle.q-expansion-item__toggle-icon.q-expansion-item__toggle-icon--rotated
) {
color: var(--brand-1);
}
:deep(.installment-no .q-field__control) {
height: 23px;
}
</style>

View file

@ -41,7 +41,7 @@ defineExpose({
defineEmits<{
(e: 'delete', id: string, noDialog?: boolean): void;
(e: 'edit', id: string, data: { name: string }): void;
(e: 'add', data: { name: string; productId: []; order: number }): void;
(e: 'add', data: { name: string; order: number }): void;
}>();
onMounted(async () => {
@ -158,8 +158,7 @@ watch(
:disable="isWorkNameEdit()"
@click="
() => {
$emit('add', { name: '', productId: [], order: 1 }),
(isAdd = true);
$emit('add', { name: '', order: 1 }), (isAdd = true);
}
"
>

View file

@ -1,25 +1,12 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import useBranchStore from 'src/stores/branch';
import useCustomerStore from 'src/stores/customer';
import SelectInput from '../shared/SelectInput.vue';
import { QSelect } from 'quasar';
import { Branch } from 'src/stores/branch/types';
import { CustomerBranch } from 'src/stores/customer/types';
const { locale } = useI18n({ useScope: 'global' });
const branchStore = useBranchStore();
const customerStore = useCustomerStore();
import SelectCustomer from '../shared/select/SelectCustomer.vue';
import SelectBranch from '../shared/select/SelectBranch.vue';
const branchId = defineModel<string>('branchId');
const customerBranchId = defineModel<string>('customerBranchId');
const agentPrice = defineModel<boolean>('agentPrice');
const special = defineModel<boolean>('special');
const branchOption = ref();
const customerOption = ref();
defineProps<{
outlined?: boolean;
readonly?: boolean;
@ -27,6 +14,7 @@ defineProps<{
employee?: boolean;
title?: string;
inputOnly?: boolean;
hideAdd?: boolean;
onCreate?: boolean;
}>();
@ -34,62 +22,6 @@ defineProps<{
defineEmits<{
(e: 'addCustomer'): void;
}>();
async function filter(
val: string,
update: (...args: unknown[]) => void,
type: 'branch' | 'customer',
) {
update(
async () => {
await init(val, type);
},
(ref: QSelect) => {
if (val !== '' && ref.options && ref.options?.length > 0) {
ref.setOptionIndex(-1);
ref.moveOptionSelection(1, true);
}
},
);
}
async function init(val: string, type: 'branch' | 'customer') {
const res =
type === 'branch'
? await branchStore.fetchList({
query: val,
pageSize: 30,
})
: await customerStore.fetchListCustomerBranch({
query: val,
pageSize: 30,
company: true,
});
if (res) {
if (type === 'branch') {
branchOption.value = (res.result as Branch[]).map((v: Branch) => ({
value: v.id,
label: v.name,
labelEN: v.nameEN,
}));
} else if (type === 'customer') {
customerOption.value = (res.result as CustomerBranch[]).map(
(v: CustomerBranch) => ({
value: v.id,
label: v.registerName || `${v.firstName} ${v.lastName}` || '-',
labelEN:
v.registerNameEN || `${v.firstNameEN} ${v.lastNameEN}` || '-',
}),
);
}
}
}
onMounted(async () => {
await init('', 'branch');
await init('', 'customer');
});
</script>
<template>
<div class="row">
@ -123,58 +55,28 @@ onMounted(async () => {
</div>
</div>
<div class="col-12 row q-col-gutter-sm">
<SelectInput
<SelectBranch
v-model:value="branchId"
:label="$t('quotation.branchVirtual')"
class="col-md-6 col-12"
simple
required
:readonly
incremental
v-model="branchId"
id="quotation-branch"
class="col-md col-12"
:option="branchOption"
:label="$t('quotation.branch')"
:option-label="locale === 'eng' ? 'labelEN' : 'label'"
:rules="[(val: string) => !!val || $t('form.error.required')]"
@filter="(val: string, update) => filter(val, update, 'branch')"
/>
<SelectInput
:readonly
incremental
v-model="customerBranchId"
class="col-md col-12"
id="quotation-customer"
:option="customerOption"
<SelectCustomer
v-model:value="customerBranchId"
:label="$t('quotation.customer')"
:option-label="locale === 'eng' ? 'labelEN' : 'label'"
:rules="[(val: string) => !!val || $t('form.error.required')]"
@filter="(val: string, update) => filter(val, update, 'customer')"
>
<template #option="{ scope }">
<q-item
clickable
v-if="scope.index === 0"
@click.stop="$emit('addCustomer')"
>
<q-item-section>
{{ $t('general.add', { text: $t('quotation.newCustomer') }) }}
</q-item-section>
</q-item>
<q-separator v-if="scope.index === 0" />
<q-item clickable v-bind="scope.itemProps">
<q-item-section>
{{ locale === 'eng' ? scope.opt.labelEN : scope.opt.label }}
</q-item-section>
</q-item>
</template>
<template #noOption>
<q-item clickable @click.stop="$emit('addCustomer')">
<q-item-section>
{{ $t('general.add', { text: $t('quotation.newCustomer') }) }}
</q-item-section>
</q-item>
</template>
</SelectInput>
:creatable-disabled-text="`(${$t('form.error.selectField', {
field: $t('quotation.branchVirtual'),
})})`"
@create="$emit('addCustomer')"
class="col-md-6 col-12"
:creatable-disabled="!branchId"
:creatable="!inputOnly"
simple
required
:readonly
/>
</div>
</div>
</template>

View file

@ -1,18 +1,25 @@
<script lang="ts" setup>
import { ref, watch } from 'vue';
import { QTableProps } from 'quasar';
import { QTableProps, QTableSlots } from 'quasar';
import { storeToRefs } from 'pinia';
import { baseUrl } from 'stores/utils';
import WorkerItem from './WorkerItem.vue';
import DeleteButton from '../button/DeleteButton.vue';
import { precisionRound } from 'src/utils/arithmetic';
import { QuotationPayload } from 'stores/quotations/types';
import {
ProductRelation,
ProductServiceList,
QuotationPayload,
} from 'stores/quotations/types';
import { formatNumberDecimal, commaInput } from 'stores/utils';
import { useConfigStore } from 'stores/config';
const props = defineProps<{
readonly?: boolean;
agentPrice: boolean;
installmentInput?: boolean;
maxInstallment?: number | null;
employeeRows?: {
foreignRefNo: string;
employeeName: string;
@ -27,7 +34,15 @@ const props = defineProps<{
}>();
defineEmits<{
(e: 'viewFile', data: ProductRelation): void;
(e: 'delete', index: number): void;
(
e: 'updateTable',
data: QuotationPayload['productServiceList'][number],
opt?: {
newInstallmentNo: number;
},
): void;
}>();
const configStore = useConfigStore();
@ -45,9 +60,13 @@ function calcPrice(c: (typeof rows.value)[number]) {
return precisionRound(
c.pricePerUnit * c.amount -
c.discount +
(c.product.calcVat
? (c.pricePerUnit * c.amount - c.discount) * (config.value?.vat || 0.07)
: 0),
precisionRound(
c.product.calcVat
? (c.pricePerUnit * (c.discount ? c.amount : 1) - c.discount) *
(config.value?.vat || 0.07)
: 0,
) *
(!c.discount ? c.amount : 1),
);
}
@ -63,19 +82,19 @@ const columns = [
{
name: 'code',
align: 'left',
label: 'general.code',
label: 'productService.product.code',
field: (v) => v.product.code,
},
{
name: 'name',
align: 'center',
label: 'productService.service.list',
label: 'quotation.productList',
field: (v) => v.product.name,
},
{
name: 'amount',
align: 'center',
label: 'general.amount',
label: 'taskOrder.amountOfEmployee',
field: 'amount',
},
{
@ -97,15 +116,15 @@ const columns = [
field: 'priceBeforeVat',
},
{
name: 'tax',
name: 'vat',
align: 'center',
label: 'general.vat',
field: 'tax',
field: 'vat',
},
{
name: 'sumPrice',
align: 'right',
label: 'quotation.sumPrice',
label: 'quotation.totalPriceBaht',
field: 'sumPrice',
},
] satisfies QTableProps['columns'];
@ -193,24 +212,63 @@ function handleCheck(
index: number,
data: QuotationPayload['productServiceList'][number],
) {
const equals = data.amount === data.workerIndex.length;
const target = data.workerIndex.indexOf(index);
if (target > -1) {
data.workerIndex.splice(target, 1);
if (equals) data.amount -= 1;
} else {
data.workerIndex.push(index);
if (equals) data.amount += 1;
}
data.amount = Math.max(data.workerIndex.length, data.amount);
}
watch(
() => props.employeeRows,
(a, b) => {
if (a === undefined || b === undefined) return;
if (a.length < b.length) {
rows.value.forEach((p) => {
const maxValue = Math.max(...p.workerIndex);
p.workerIndex = p.workerIndex.filter((i) => i !== maxValue);
});
}
(current, before) => {
if (current === undefined || before === undefined) return;
rows.value.forEach((items) => {
const mapping = items.workerIndex.map((v) => before[v]);
const incoming = current.filter(
(lhs) =>
!before.find((rhs) => {
console.log(lhs, rhs);
return JSON.stringify(lhs) === JSON.stringify(rhs);
}),
);
const selected = mapping.concat(incoming);
items.workerIndex = selected
.map((lhs) =>
current.findIndex((rhs) => {
return JSON.stringify(lhs) === JSON.stringify(rhs);
}),
)
.filter((v) => v !== -1);
items.amount = items.workerIndex.length;
});
},
);
watch(
() => props.maxInstallment,
() => {
if (!props.maxInstallment) return;
let test: ProductServiceList[] = [];
const items = groupByServiceId(
rows.value.map((v, i) => Object.assign(v, { i })),
) || [{ title: '', product: [] }];
items.forEach((p) => {
test = test.concat(p.product.flatMap((item) => item));
});
test.forEach((p) => {
if ((props.maxInstallment || 0) < (p.installmentNo || 0)) {
p.installmentNo = Number(props.maxInstallment);
}
});
},
);
</script>
@ -224,26 +282,24 @@ watch(
:key="i"
class="q-pb-md"
>
<div
v-if="item.title && !item.title.includes('_product')"
class="q-py-sm q-px-md bordered"
style="background: hsla(var(--orange-5-hsl) / 0.1)"
>
<q-avatar
class="q-mr-lg"
style="background: var(--orange-5); color: var(--surface-1)"
size="sm"
>
<q-icon size="xs" name="mdi-server-outline" />
</q-avatar>
{{ item.title }}
</div>
<q-table
flat
bordered
hide-pagination
:columns="columns"
:columns="
installmentInput
? [
...columns.slice(0, 1),
{
name: 'periodNo',
align: 'left',
label: 'quotation.periodNo',
field: (v) => v.product.code,
},
...columns.slice(1),
]
: columns
"
:rows="item.product"
class="full-width"
:no-data-label="$t('general.noDataTable')"
@ -260,17 +316,51 @@ watch(
</q-tr>
</template>
<template #body="props">
<template
#body="props: {
row: Required<QuotationPayload['productServiceList'][number]>;
} & Omit<Parameters<QTableSlots['body']>[0], 'row'>"
>
<q-tr>
<q-td class="text-center">{{ props.rowIndex + 1 }}</q-td>
<q-td>{{ props.row.product.code }}</q-td>
<q-td v-if="installmentInput">
<q-input
:readonly
:bg-color="readonly ? 'transparent' : ''"
dense
min="1"
:max="maxInstallment"
outlined
input-class="text-right"
type="number"
style="width: 60px"
:model-value="props.row.installmentNo"
@update:model-value="
(v) => {
$emit('updateTable', props.row, {
newInstallmentNo: Number(v),
});
props.row.installmentNo = Number(v);
}
"
></q-input>
</q-td>
<q-td class="text-center">{{ props.row.product.code }}</q-td>
<q-td style="width: 100%">
<q-avatar class="q-mr-sm" size="md">
<q-icon
class="full-width full-height"
name="mdi-shopping-outline"
:style="`color: var(--teal-10); background: hsla(var(--teal-${$q.dark.isActive ? '8' : '10'}-hsl)/0.15)`"
/>
<q-img
class="text-center"
:ratio="1"
:src="`${baseUrl}/product/${props.row.product.id}/image/${props.row.product.selectedImage}`"
>
<template #error>
<q-icon
class="full-width full-height"
name="mdi-shopping-outline"
:style="`color: var(--teal-10); background: hsla(var(--teal-${$q.dark.isActive ? '8' : '10'}-hsl)/0.15)`"
/>
</template>
</q-img>
</q-avatar>
{{ props.row.product.name }}
</q-td>
@ -281,10 +371,16 @@ watch(
dense
outlined
:type="readonly ? 'text' : 'number'"
input-class="text-center"
style="width: 70px"
min="0"
min="1"
debounce="500"
v-model="props.row.amount"
@update:model-value="
(v) => {
$emit('updateTable', props.row);
}
"
/>
</q-td>
<q-td align="right">
@ -307,21 +403,30 @@ watch(
outlined
input-class="text-right"
style="width: 90px"
debounce="500"
:model-value="
commaInput(props.row.discount.toString() || '0')
discount4Show[props.rowIndex] ||
commaInput(props.row.discount?.toString() || '0')
"
@blur="
() => {
props.row.discount = Number(
discount4Show[props.rowIndex].replace(/,/g, ''),
);
if (props.row.discount % 1 === 0) {
const [, dec] =
discount4Show[props.rowIndex].split('.');
if (!dec) {
discount4Show[props.rowIndex] += '.00';
}
}
}
"
@update:model-value="
(v) => {
if (typeof v === 'string')
discount4Show[props.rowIndex] = commaInput(v);
const x = parseFloat(
discount4Show[props.rowIndex] &&
typeof discount4Show[props.rowIndex] === 'string'
? discount4Show[props.rowIndex].replace(/,/g, '')
: '',
discount4Show[props.rowIndex] = commaInput(
v?.toString() || '0',
'string',
);
props.row.discount = x;
}
"
/>
@ -347,10 +452,7 @@ watch(
{{ formatNumberDecimal(calcPrice(props.row), 2) }}
</q-td>
<q-td>
<div
class="row items-center full-width justify-end no-wrap"
v-if="!readonly"
>
<div class="row items-center full-width justify-end no-wrap">
<q-btn
@click.stop="openEmployeeTable(item.title, props.rowIndex)"
dense
@ -377,8 +479,10 @@ watch(
background-color: hsla(var(--positive-bg) / 0.1);
color: hsl(var(--positive-bg));
"
@click="$emit('viewFile', props.row.product)"
/>
<DeleteButton
v-if="!readonly"
iconOnly
@click="$emit('delete', props.row.i)"
/>
@ -395,8 +499,9 @@ watch(
>
<q-td colspan="100%" style="padding: 16px">
<WorkerItem
:readonly
:checkable="!readonly"
@check="(wokerIndex) => handleCheck(wokerIndex, props.row)"
checkable
fallback-img="/images/employee-avatar.png"
inTable
hideQuantity

View file

@ -1,24 +1,34 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue/dist/iconify.js';
import { formatNumberDecimal } from 'src/stores/utils';
import BadgeComponent from 'components/BadgeComponent.vue';
import KebabAction from '../shared/KebabAction.vue';
import MainButton from '../button/MainButton.vue';
defineProps<{
type?:
| 'fullAmountCash'
| 'installmentsCash'
| 'fullAmountBill'
| 'installmentsBill'
| string;
title?: string;
code?: string;
amount?: number;
date?: string;
status?: string;
workerCount?: number;
workerMax?: number;
createdAt?: string;
validUntil?: string;
customerName?: string;
reporter?: string;
totalPrice?: number;
urgent?: boolean;
hidePreview?: boolean;
badgeColor?: string;
hideKebabView?: boolean;
hideKebabEdit?: boolean;
hideAction?: boolean;
customData?: {
label: string;
value: string | number | unknown;
slotName?: string;
}[];
}>();
defineEmits<{
@ -30,52 +40,62 @@ defineEmits<{
(e: 'example'): void;
(e: 'preview'): void;
}>();
const rand = Math.random();
</script>
<template>
<div class="surface-1 rounded bordered q-pa-sm quo-card">
<div
class="surface-1 rounded q-pa-sm quo-card bordered"
:class="{ 'urgent-card': urgent }"
:style="{ '--animation-delay': rand + 's' }"
>
<!-- SEC: header -->
<header class="row items-center no-wrap">
<div
class="badge-card rounded q-pa-xs"
:class="{ [`badge-card__${type}`]: true }"
>
{{ $t(`quotation.type.${type}`) }}
<div v-if="urgent" class="q-mr-sm" style="font-size: 90%">
<BadgeComponent
icon="mdi-fire"
:title="$t('general.urgent2')"
hsla-color="--gray-1-hsl"
hsla-background="--red-8-hsl"
solid
/>
</div>
<div class="column q-ml-md relative-position" style="font-size: 12px">
<span>
{{ $t('general.itemNo', { msg: $t('quotation.title') }) }}
</span>
<span
class="text-caption row items-center"
:class="urgent ? 'urgent' : 'app-text-muted'"
style="top: 10px"
>
{{ code }}
<q-icon
v-if="urgent"
name="mdi-fire"
size="xs"
style="position: absolute; top: 7px"
></q-icon>
</span>
<div class="q-mr-sm" style="font-size: 90%">
<BadgeComponent
:title="status"
:hsla-color="badgeColor || '--blue-6-hsl'"
:border="urgent"
/>
</div>
<nav class="col text-right">
<q-btn
v-if="!hidePreview"
flat
dense
rounded
icon="mdi-play-box-outline"
size="12px"
:title="$t('preview.doc')"
@click.stop="$emit('preview')"
/>
<q-btn
flat
dense
rounded
icon="mdi-eye-outline"
size="12px"
:title="$t('general.viewDetail')"
@click.stop="$emit('view')"
/>
<KebabAction
v-if="!hideAction"
:idName="code"
status="ACTIVE"
hide-toggle
use-link
use-upload
hide-delete
:hide-view="hideKebabView"
:hide-edit="hideKebabEdit"
@view="$emit('view')"
@edit="$emit('edit')"
@link="$emit('link')"
@ -85,48 +105,87 @@ defineEmits<{
</nav>
</header>
<div class="ellipsis q-px-xs">
<b>{{ title || '-' }}</b>
<q-tooltip anchor="bottom start" self="top left">
{{ title || '-' }}
</q-tooltip>
</div>
<div class="ellipsis q-px-xs q-mb-sm app-text-muted" style="font-size: 80%">
{{ code || '-' }}
<q-tooltip anchor="bottom start" self="top left">
{{ title || '-' }}
</q-tooltip>
</div>
<!-- SEC: body -->
<section class="row no-wrap q-py-md">
<q-img src="/images/quotation-avatar.png" width="4rem" class="q-mr-lg" />
<div class="column">
<span class="col q-pt-sm">{{ title || '-' }}</span>
<span class="app-text-muted">x {{ amount || '0' }}</span>
</div>
<div
class="col text-right app-text-muted q-mr-md self-end"
style="font-size: 12px"
<section
class="rounded q-px-sm"
:class="{
'surface-1': urgent,
'surface-2': !urgent,
}"
>
<article
class="q-py-sm"
:class="{
row: $q.screen.gt.sm,
column: $q.screen.lt.sm,
}"
v-if="customData && customData?.length > 0"
>
{{ date || '-' }}
</div>
<template v-for="cData in customData" :key="cData.label">
<template v-if="cData.slotName">
<slot :name="cData.slotName" :props="cData" />
</template>
<template v-else>
<div class="col-4 app-text-muted q-pr-sm">
{{ cData.label || '-' }}
</div>
<div class="col-8">{{ cData.value || '-' }}</div>
</template>
</template>
</article>
<article v-else class="row q-py-sm">
<div class="col-4 app-text-muted q-pr-sm">
{{ $t('quotation.customerName') }}
</div>
<div class="col-8">{{ customerName || '-' }}</div>
<div class="col-4 app-text-muted q-pr-sm">
{{ $t('quotation.actor') }}
</div>
<div class="col-8">{{ reporter || '-' }}</div>
<div class="col-4 app-text-muted q-pr-sm">
{{ $t('quotation.employee') }}
</div>
<div class="col-8">
<BadgeComponent
:hsla-color="badgeColor"
icon="mdi-account-multiple-outline"
:title="[workerCount, workerMax].join(' / ')"
/>
</div>
<div class="col-4 app-text-muted q-pr-sm">
{{ $t('general.createdAt') }}
</div>
<div class="col-8">
{{ createdAt }}
</div>
<div class="col-4 app-text-muted q-pr-sm">
{{ $t('general.validUntil') }}
</div>
<div class="col-8">
{{ validUntil }}
</div>
<div class="col-4 app-text-muted q-pr-sm">
{{ $t('quotation.totalPrice') }}
</div>
<div class="col-8">
{{ formatNumberDecimal(totalPrice || 0, 2) }}
</div>
</article>
</section>
<q-separator />
<section class="row q-py-sm">
<div class="col-3 app-text-muted">{{ $t('quotation.customerName') }}</div>
<div class="col-9">{{ customerName || '-' }}</div>
<div class="col-3 app-text-muted">{{ $t('quotation.actor') }}</div>
<div class="col-9">{{ reporter || '-' }}</div>
</section>
<q-separator />
<footer class="row no-wrap items-center q-mt-sm" style="text-wrap: nowrap">
<Icon
class="q-mr-xs"
icon="ph:money-fill"
style="font-size: 24px; color: var(--green-9)"
/>
{{ $t('quotation.totalPriceBaht') }} :
<div class="q-ml-xs" style="color: var(--orange-5)">
{{ formatNumberDecimal(totalPrice || 0, 2) }}
</div>
<MainButton
outlined
icon="mdi-play-box-outline"
color="207 96% 32%"
class="q-ml-auto"
@click="$emit('preview')"
>
{{ $t('general.view', { msg: $t('general.example') }) }}
</MainButton>
</footer>
</div>
</template>
@ -164,7 +223,37 @@ span {
height: 12px;
}
.urgent {
color: hsl(var(--red-6-hsl));
.urgent-card {
--_color: var(--red-7-hsl);
background-color: hsla(var(--red-7-hsl) / 0.07) !important;
border: 0.5px solid var(--red-6) !important;
animation: status 1s infinite;
animation-delay: var(--animation-delay);
.code {
color: var(--red-6);
}
.tag {
font-size: 12px;
border-radius: var(--radius-2);
background: hsl(var(--red-7-hsl));
color: white;
-webkit-box-shadow: 0px 0px 6px 0px rgba(240, 62, 62, 1);
-moz-box-shadow: 0px 0px 6px 0px rgba(240, 62, 62, 1);
box-shadow: 0px 0px 6px 0px rgba(240, 62, 62, 1);
}
}
@keyframes status {
0% {
box-shadow: 0x 0px 0px hsla(var(--_color) / 1);
}
50% {
box-shadow: 0px 0px 1px 4px hsla(var(--_color) / 0.3);
}
100% {
box-shadow: 0px 0px 4px 12px hsla(var(--_color) / 0);
}
}
</style>

View file

@ -0,0 +1,192 @@
<script lang="ts" setup>
import { QTableProps } from 'quasar';
import { dateFormat } from 'src/utils/datetime';
import { formatNumberDecimal } from 'stores/utils';
import BadgeComponent from 'components/BadgeComponent.vue';
import KebabAction from 'components/shared/KebabAction.vue';
import { hslaColors } from 'src/pages/05_quotation/constants';
const props = withDefaults(
defineProps<{
rows: QTableProps['rows'];
columns: QTableProps['columns'];
grid?: boolean;
visibleColumns?: string[];
hideEdit?: boolean;
}>(),
{
row: () => [],
column: () => [],
grid: false,
visibleColumns: () => [],
},
);
function payCondition(value: string) {
if (value === 'Full') return 'quotation.type.fullAmountCash';
if (value === 'Split') return 'quotation.type.installmentsCash';
if (value === 'SplitCustom') return 'quotation.type.installmentsCustomCash';
return '';
}
defineEmits<{
(e: 'preview', data: any): void;
(e: 'view', data: any): void;
(e: 'edit', data: any): void;
(e: 'delete', data: any): void;
}>();
</script>
<template>
<q-table
v-bind="props"
flat
hide-pagination
card-container-class="q-col-gutter-sm"
:rows-per-page-options="[0]"
class="full-width"
>
<template v-slot:header="props">
<q-tr
style="background-color: hsla(var(--info-bg) / 0.07)"
:props="props"
>
<q-th v-for="col in props.cols" :key="col.name" :props="props">
{{ col.label && $t(col.label) }}
</q-th>
</q-tr>
</template>
<template v-slot:body="props">
<q-tr :class="{ urgent: props.row.urgent }">
<q-td v-if="visibleColumns.includes('order')">
{{ props.rowIndex + 1 }}
</q-td>
<q-td v-if="visibleColumns.includes('workName')">
<div class="column">
<div class="col-6">{{ props.row.workName }}</div>
<div class="col-6 app-text-muted">{{ props.row.code }}</div>
</div>
</q-td>
<q-td v-if="visibleColumns.includes('createdAt')">
{{ dateFormat(props.row.createdAt) }}
</q-td>
<q-td v-if="visibleColumns.includes('dueDate')">
{{ dateFormat(props.row.dueDate) }}
</q-td>
<q-td v-if="visibleColumns.includes('contactName')">
{{ props.row.contactName }}
</q-td>
<q-td v-if="visibleColumns.includes('actor')">
{{ props.row.createdBy.firstName }}
</q-td>
<q-td v-if="visibleColumns.includes('summaryPrice')">
{{ formatNumberDecimal(props.row.finalPrice, 2) }}
</q-td>
<q-td v-if="visibleColumns.includes('payCondition')">
{{ $t(payCondition(props.row.payCondition)) }}
</q-td>
<q-td v-if="visibleColumns.includes('status')">
<div style="min-width: 150px">
<BadgeComponent
:title="$t(`quotation.status.${props.row.quotationStatus}`)"
:hsla-color="hslaColors[props.row.quotationStatus] || ''"
/>
<BadgeComponent
class="q-ml-xs"
icon="mdi-fire"
v-if="props.row.urgent"
:title="$t('general.urgent2')"
hsla-color="--gray-1-hsl"
hsla-background="--red-8-hsl"
solid
/>
</div>
</q-td>
<q-td class="text-right">
<q-btn
:id="`btn-eye-${props.row.firstName}`"
icon="mdi-play-box-outline"
size="sm"
dense
round
flat
@click.stop="$emit('preview', props.row.id)"
/>
<q-btn
:id="`btn-eye-${props.row.firstName}`"
icon="mdi-eye-outline"
size="sm"
dense
round
flat
@click.stop="$emit('view', props.row)"
/>
<KebabAction
:idName="`btn-kebab-${props.row.firstName}`"
status="'ACTIVE'"
hide-toggle
hide-delete
:hide-edit="hideEdit"
@view="$emit('view', props.row)"
@edit="$emit('edit', props.row)"
@delete="$emit('delete', props.row.id)"
/>
</q-td>
</q-tr>
</template>
<template v-slot:item="props">
<slot name="grid" :item="props" />
</template>
</q-table>
</template>
<style scoped>
.q-table tr.urgent {
background: hsla(var(--red-6-hsl) / 0.03);
}
.q-table tr.urgent td:first-child {
&::after {
content: ' ';
display: block;
position: absolute;
left: 0;
top: 15%;
bottom: 15%;
background: var(--red-8);
width: 4px;
border-radius: 99rem;
animation: blink 1s infinite;
}
}
@keyframes blink {
0% {
background: var(--red-8);
}
50% {
background: var(--red-3);
}
100% {
background: var(--red-8);
}
}
</style>

View file

@ -1,17 +1,18 @@
<script lang="ts" setup>
import { QTableProps } from 'quasar';
import TableComponents from 'src/components/TableComponents.vue';
import { onMounted } from 'vue';
import { watch } from 'vue';
const employeeAmount = defineModel<number>('employeeAmount', { default: 1 });
defineEmits<{
(e: 'delete', index: number): void;
(e: 'check', index: number): void;
}>();
withDefaults(
const props = withDefaults(
defineProps<{
readonly?: boolean;
employeeAmount?: number;
fallbackImg?: string;
hideQuantity?: boolean;
checkable?: boolean;
@ -24,13 +25,12 @@ withDefaults(
age: string;
nationality: string;
documentExpireDate: string;
imgUrl: string;
imgUrl?: string;
status: string;
}[];
}>(),
{
rows: () => [],
employeeAmount: 0,
},
);
@ -86,6 +86,14 @@ const columns = [
field: 'action',
},
] satisfies QTableProps['columns'];
watch(
() => props.rows,
() => {
if (props.readonly) return;
employeeAmount.value = props.rows.length;
},
);
</script>
<template>
<div>
@ -110,18 +118,27 @@ const columns = [
]
: columns
"
:rows
:rows="
readonly && inTable
? rows.filter((_, index) => checkList.includes(index))
: rows
"
hidePagination
:customColumn="['check']"
@delete="(i) => $emit('delete', i)"
>
<template v-slot:img-column="{ props }">
<q-avatar class="q-mr-sm" size="md">
<q-img :src="props.row.imgUrl" class="full-height full-width">
<q-img
v-if="props.row.imgUrl"
:src="props.row.imgUrl"
class="full-height full-width"
>
<template #error>
<q-img :src="fallbackImg" :ratio="1" />
</template>
</q-img>
<q-img v-else :src="fallbackImg" :ratio="1" />
<div
class="absolute-bottom-right"
style="
@ -157,15 +174,23 @@ const columns = [
</TableComponents>
<div v-if="!hideQuantity" class="row q-pt-md items-center">
<span class="q-ml-auto">
<span class="q-ml-auto q-mr-sm">
{{ $t('general.numberOf', { msg: $t('quotation.employee') }) }}
</span>
<div
class="surface-tab bordered rounded flex items-center justify-center q-mx-md"
style="width: 30px; height: 30px"
>
{{ employeeAmount || '0' }}
</div>
<q-input
for="worker-count"
dense
outlined
style="width: 67px"
:type="readonly ? 'text' : 'number'"
class="col-1"
input-class="text-center"
:readonly
hide-bottom-space
:min="rows.length"
v-model="employeeAmount"
/>
</div>
</div>
</template>

View file

@ -0,0 +1,86 @@
<script lang="ts" setup>
import SelectInput from '../shared/SelectInput.vue';
import useOptionStore from 'src/stores/options';
const optionStore = useOptionStore();
defineProps<{
dense?: boolean;
outlined?: boolean;
readonly?: boolean;
onDrawer?: boolean;
}>();
const group = defineModel('group', { default: '' });
const name = defineModel('name', { default: '' });
const nameEn = defineModel('nameEn', { default: '' });
type Options = { label: string; value: string };
</script>
<template>
<div class="row col-12">
<div class="col-12 q-pb-sm row items-center">
<q-icon
flat
size="xs"
class="q-pa-sm rounded q-mr-sm"
color="info"
name="mdi-office-building-outline"
style="background-color: var(--surface-3)"
/>
<span class="text-body1 text-weight-bold">
{{ $t(`form.field.basicInformation`) }}
</span>
</div>
<div class="col-12 row q-col-gutter-sm">
<SelectInput
:disable="!readonly && onDrawer"
:readonly="readonly"
for="input-agencies-code"
:label="$t('agencies.group')"
option-label="value"
:option="
optionStore.globalOption?.agenciesType.sort(
(lhs: Options, rhs: Options) => lhs.value.localeCompare(rhs.value),
)
"
v-model="group"
>
<template v-slot:option="{ scope, opt }">
<q-item v-bind="scope.itemProps" clickable>
{{ opt.value }} ({{ opt.label }})
</q-item>
</template>
</SelectInput>
<q-input
for="input-agencies-name"
dense
outlined
:readonly="readonly"
hide-bottom-space
class="col"
:label="$t('agencies.name')"
v-model="name"
:rules="[(val: string) => !!val || $t('form.error.required')]"
/>
<q-input
for="input-agencies-name-en"
dense
outlined
:readonly="readonly"
hide-bottom-space
class="col"
:label="'Agencies Name'"
v-model="nameEn"
:rules="[
(val: string) => !!val || $t('form.error.required'),
(val: string) =>
/^[A-Za-z0-9.,' -]+$/.test(val) || $t('form.error.letterOnly'),
]"
/>
</div>
</div>
</template>
<style scoped></style>

View file

@ -0,0 +1,68 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue/dist/iconify.js';
defineEmits<{ (e: 'labelClick', value: string, index: number): void }>();
withDefaults(
defineProps<{
label: string;
value?: string | string[];
icon?: string;
iconSize?: string;
tooltip?: boolean;
clickable?: boolean;
}>(),
{
label: '-',
value: '-',
},
);
</script>
<template>
<article class="row items-center">
<Icon
v-if="icon"
:icon
class="app-text-muted q-pr-sm"
:width="iconSize || '2rem'"
/>
<span class="row col">
<span class="col-12 app-text-muted-2" style="font-size: 10px">
{{ label }}
</span>
<span class="col-12 ellipsis">
<slot name="value">
<span
:class="{ 'link cursor-pointer': clickable }"
v-if="typeof value === 'string'"
@click="$emit('labelClick', value, null)"
>
{{ value }}
<q-tooltip v-if="tooltip" :delay="500">{{ value }}</q-tooltip>
</span>
<span v-else :class="{ 'link cursor-pointer': clickable }">
<span
v-for="(item, index) in value"
:key="index"
@click="$emit('labelClick', item, index)"
class="link cursor-pointer"
>
{{ item }}
<span v-if="index < value.length - 1">,&nbsp;</span>
<q-tooltip v-if="tooltip" :delay="500">{{ item }}</q-tooltip>
</span>
</span>
</slot>
</span>
</span>
</article>
</template>
<style scoped>
.link {
color: hsl(var(--info-bg));
text-decoration: underline;
}
</style>

View file

@ -0,0 +1,120 @@
<script setup lang="ts">
import {
PropDate,
PropNumber,
PropOptions,
PropString,
} from 'src/stores/product-service/types';
import SelectInput from '../shared/SelectInput.vue';
import DatePicker from '../shared/DatePicker.vue';
defineProps<{
prop: PropString | PropNumber | PropDate | PropOptions;
placeholder?: string;
readonly?: boolean;
disable?: boolean;
}>();
const model = defineModel<string | number | null | undefined>();
function numberDisplay(prop: PropNumber, data: number): string {
if (isNaN(data)) data = 0;
let formattedNumber: string;
if (prop.comma && prop.decimal) {
formattedNumber = data.toLocaleString('en-US', {
minimumFractionDigits: prop.decimalPlace,
maximumFractionDigits: prop.decimalPlace,
});
} else if (prop.comma && !prop.decimal) {
formattedNumber = data.toLocaleString('en-US', {
minimumFractionDigits: 0,
maximumFractionDigits: 0,
});
} else if (!prop.comma && prop.decimal) {
formattedNumber = data.toFixed(prop.decimalPlace);
} else {
formattedNumber = Math.round(data).toString();
}
return formattedNumber;
}
function numberParse(formatted: string, prop: PropNumber): number {
let parsedNumber: number;
let cleanedFormatted = formatted.replace(/,/g, '');
if (prop.decimal) {
parsedNumber = parseFloat(cleanedFormatted);
} else {
parsedNumber = parseInt(cleanedFormatted, 10);
}
if (isNaN(parsedNumber)) {
return 0;
}
return parsedNumber;
}
</script>
<template>
<q-input
v-if="prop.type === 'string'"
:readonly
:disable
class="col-7"
:model-value="readonly || disable ? model || '-' : model"
dense
outlined
:placeholder
:maxlength="prop.isPhoneNumber ? prop.phoneNumberLength : undefined"
@update:model-value="
(v) => {
model = v;
}
"
@focus="(e) => (e.target as HTMLInputElement).select()"
/>
<q-input
v-if="prop.type === 'number'"
:readonly
:disable
class="col-7"
debounce="500"
dense
outlined
:model-value="numberDisplay(prop, Number(model))"
@update:model-value="
(v) => {
let cleanedFormatted = v?.toString().replace(/,/g, '');
const x = numberParse(
numberDisplay(prop as PropNumber, Number(cleanedFormatted)),
prop as PropNumber,
);
model = x;
}
"
@focus="(e) => (e.target as HTMLInputElement).select()"
/>
<DatePicker
v-if="prop.type === 'date'"
:readonly
:disabled="disable"
class="col-7"
v-model="model as string"
/>
<SelectInput
v-if="prop.type === 'array'"
:readonly
:disable
:label="$t('form.selection')"
v-model="model as string"
class="col-7"
:option="prop.options.map((opt) => ({ label: opt, value: opt }))"
/>
</template>
<style scoped></style>

View file

@ -1,23 +0,0 @@
<script setup lang="ts">
defineProps<{
label: string;
color?: string;
}>();
</script>
<template>
<div
class="q-pl-md q-pr-sm row items-center justify-between rounded hover-item"
:style="`border:1px solid ${color};color:${color}`"
@click.stop="$emit('viewDetail')"
>
{{ $t(label) }}
<q-icon name="mdi-arrow-right" class="q-ml-md" />
</div>
</template>
<style scoped>
.hover-item:hover {
background-color: var(--surface-3);
}
</style>

View file

@ -0,0 +1,46 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue';
defineProps<{
icon?: string;
title?: string;
titleI18n?: string;
hslaColor?: string;
hslaBackground?: string;
hslaBorder?: string;
border?: boolean;
solid?: boolean;
transparency?: number;
hideIcon?: boolean;
}>();
</script>
<template>
<div
class="rounded q-px-sm flex-center"
:style="{
color: `hsla(var(${hslaColor || '--green-6-hsl'}) / 1)`,
background: `hsla(var(${hslaBackground || hslaColor || '--green-6-hsl'}) / ${solid ? 1 : 0.15})`,
border: border
? `0.5px solid hsla(var(${hslaBorder || hslaBackground || hslaColor || '--green-6-hsl'}) / ${solid ? 1 : 0.15})`
: undefined,
}"
>
<Icon
v-if="!hideIcon"
:icon="icon || 'mdi-circle-medium'"
style="margin-right: 0.25rem"
/>
<slot name="label">
{{ title || (!!titleI18n ? $t(titleI18n) : '-') }}
</slot>
<slot name="append"></slot>
</div>
</template>
<style scoped>
div {
display: inline-flex;
}
</style>

View file

@ -6,8 +6,8 @@ import { ref } from 'vue';
const props = defineProps<{
action: (...args: unknown[]) => void;
checkData: (...args: unknown[]) => {
oldData: { filName: string; value: string }[];
newData: { filName: string; value: string }[];
oldData: { nameField: string; value: string }[];
newData: { nameField: string; value: string }[];
};
cancel?: (...args: unknown[]) => void;
}>();
@ -81,7 +81,7 @@ onMounted(() => {
<div v-for="v in checkData().oldData">
<span class="text-regular app-text-muted">
{{ v.filName }}
{{ v.nameField }}
</span>
: {{ v.value }}
</div>
@ -118,7 +118,7 @@ onMounted(() => {
<div v-for="v in checkData().newData">
<span class="text-regular app-text-muted">
{{ v.filName }}
{{ v.nameField }}
</span>
: {{ v.value }}
</div>

View file

@ -21,6 +21,7 @@ defineProps<{
edit?: boolean;
hideFooter?: boolean;
hideDelete?: boolean;
hideBtn?: boolean;
readonly?: boolean;
saveAmount?: number;
@ -68,44 +69,46 @@ const currentTab = defineModel<string>('currentTab');
}"
>
<div class="row items-center">
<div v-if="!readonly && isEdit && edit" class="row">
<q-btn
round
flat
id="closeDialog"
icon="mdi-arrow-left"
padding="xs"
class="q-mr-md"
:class="{ dark: $q.dark.isActive }"
style="color: var(--brand-1)"
@click="undo"
/>
<div style="width: 31.98px"></div>
</div>
<div v-if="!readonly && !isEdit && edit">
<q-btn
round
flat
id="editDialog"
icon="mdi-pencil-outline"
padding="xs"
class="q-mr-md"
:class="{ dark: $q.dark.isActive }"
style="color: var(--brand-1)"
@click="editData"
/>
<q-btn
v-if="edit && !hideDelete"
round
flat
id="deleteDialog"
icon="mdi-trash-can-outline"
padding="xs"
:class="{ dark: $q.dark.isActive }"
style="color: hsl(var(--negative-bg))"
@click="deleteData"
/>
</div>
<template v-if="!hideBtn">
<div v-if="!readonly && isEdit && edit" class="row">
<q-btn
round
flat
id="closeDialog"
icon="mdi-arrow-left"
padding="xs"
class="q-mr-md"
:class="{ dark: $q.dark.isActive }"
style="color: var(--brand-1)"
@click="undo"
/>
<div style="width: 31.98px"></div>
</div>
<div v-if="!readonly && !isEdit && edit">
<q-btn
round
flat
id="editDialog"
icon="mdi-pencil-outline"
padding="xs"
class="q-mr-md"
:class="{ dark: $q.dark.isActive }"
style="color: var(--brand-1)"
@click="editData"
/>
<q-btn
v-if="edit && !hideDelete"
round
flat
id="deleteDialog"
icon="mdi-trash-can-outline"
padding="xs"
:class="{ dark: $q.dark.isActive }"
style="color: hsl(var(--negative-bg))"
@click="deleteData"
/>
</div>
</template>
<div style="width: 31.98px"></div>
<div class="col text-subtitle1 text-weight-bold text-center">
@ -140,18 +143,21 @@ const currentTab = defineModel<string>('currentTab');
{{ badgeLabel }}
</text>
</div>
<CancelButton
icon-only
id="btn-form-close"
@click="
() => {
modal = beforeClose ? beforeClose() : !modal;
close?.();
}
"
type="reset"
resetValidation
/>
<slot name="top-append" />
<div>
<CancelButton
icon-only
id="btn-form-close"
@click="
() => {
modal = beforeClose ? beforeClose() : !modal;
close?.();
}
"
type="reset"
resetValidation
/>
</div>
</div>
</div>

View file

@ -44,7 +44,11 @@ defineEmits<{
style="z-index: 1; cursor: pointer"
@mouseover="showOverlay = true"
@mouseleave="showOverlay = false"
@click.stop="$emit('view')"
@click.stop="
() => {
if (readonly === false) $emit('view');
}
"
>
<div
v-if="img"
@ -108,7 +112,11 @@ defineEmits<{
>
<div
class="upload-overlay absolute-bottom flex items-center justify-center"
@click.stop="$emit('edit')"
@click.stop="
() => {
if (readonly === false) $emit('edit');
}
"
>
{{
labelAction === undefined

View file

@ -52,12 +52,14 @@ const onCreateData = defineModel<{
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL;
const reader = new FileReader();
const inputFile = (() => {
const { inputFile, resetInputFile } = (() => {
const _form = document.createElement('form');
const _element = document.createElement('input');
_element.type = 'file';
_element.accept = 'image/*';
_element.addEventListener('change', change);
return _element;
_form.appendChild(_element);
return { inputFile: _element, resetInputFile: () => _form.reset() };
})();
const selectedImg = ref('');
@ -83,6 +85,7 @@ reader.addEventListener('load', () => {
});
function browse() {
resetInputFile();
inputFile?.click();
}
@ -219,11 +222,7 @@ watch(
unelevated
round
v-if="!changeDisabled"
@click="
() => {
inputFile?.click();
}
"
@click="browse"
style="color: hsla(var(--stone-0-hsl) / 0.7)"
></q-btn>
<q-btn

View file

@ -7,9 +7,9 @@ defineProps<{
}>();
</script>
<template>
<div>
<div class="text-center">
<q-img
src="no-data.png"
src="/no-data.png"
:style="{
height: size ? `${size}px` : '120px',
width: size ? `${size + 2}px` : '123px',

View file

@ -20,6 +20,7 @@ withDefaults(
active-design="outline"
gutter="sm"
boundary-numbers
:max-pages="4"
@update:model-value="fetchData"
/>
</template>

View file

@ -0,0 +1,21 @@
<script setup lang="ts">
const pageSize = defineModel<number>({ required: true });
</script>
<template>
<q-btn-dropdown dense unelevated :label="pageSize" class="bordered q-pl-md">
<q-list>
<q-item
v-for="v in [10, 30, 50, 100, 500, 1000]"
:key="v"
clickable
v-close-popup
@click="pageSize = v"
>
<q-item-section>
<q-item-label>{{ v }}</q-item-label>
</q-item-section>
</q-item>
</q-list>
</q-btn-dropdown>
</template>

View file

@ -1,6 +1,7 @@
<script setup lang="ts">
import { ref } from 'vue';
import ToggleButton from './button/ToggleButton.vue';
import { Icon } from '@iconify/vue/dist/iconify.js';
defineProps<{
img?: string | null;
@ -12,6 +13,7 @@ defineProps<{
toggleTitle?: string;
fallbackImg?: string;
fallbackCover?: string;
prefix: string;
hideFade?: boolean;
hideActive?: boolean;
@ -47,7 +49,7 @@ const smallBanner = ref(false);
</script>
<template>
<q-img
v-if="!smallBanner"
v-if="!smallBanner && $q.screen.gt.xs"
fit="cover"
class="cover rounded bordered relative-position"
:style="`height: ${tabsList ? '180px' : '10vw'}`"
@ -120,7 +122,7 @@ const smallBanner = ref(false);
color: `${color || 'white'}`,
}"
>
<q-icon :name="icon || 'mdi-account'" />
<Icon :icon="icon || 'mdi-account'" />
</div>
</template>
</q-img>
@ -132,7 +134,7 @@ const smallBanner = ref(false);
color: `${color || 'white'}`,
}"
>
<q-icon :name="icon || 'mdi-account'" />
<Icon :icon="icon || 'mdi-account'" />
</div>
</template>
</q-img>
@ -145,7 +147,7 @@ const smallBanner = ref(false);
color: `${color || 'white'}`,
}"
>
<q-icon :name="icon || 'mdi-account'" />
<Icon :icon="icon || 'mdi-account'" />
</div>
<q-badge
v-if="!hideActive"
@ -167,7 +169,7 @@ const smallBanner = ref(false);
<Transition name="slide-fade">
<div
v-if="showOverlay && !readonly"
v-if="showOverlay && !readonly && !noImageAction"
class="absolute text-caption full-width full-height"
style="border-radius: 50% 50%; overflow: hidden"
:class="{ dark: $q.dark.isActive }"
@ -200,7 +202,12 @@ const smallBanner = ref(false);
{{ title }}
</q-tooltip>
</span>
<span v-if="title" class="app-text-muted">{{ caption }}</span>
<span
v-if="title"
:class="$q.dark.isActive ? 'foreground' : 'app-text-muted'"
>
{{ caption }}
</span>
</div>
<!-- icon -->
@ -211,7 +218,8 @@ const smallBanner = ref(false);
>
<span
v-if="useToggle && toggleTitle"
class="q-mr-md app-text-muted-2"
class="q-mr-md"
:class="$q.dark.isActive ? 'foreground' : 'app-text-muted-2'"
>
{{ toggleTitle }}
</span>
@ -257,7 +265,7 @@ const smallBanner = ref(false);
>
<q-tab
v-for="tab in tabsList"
:id="`tab-${tab.label}`"
:id="`${prefix}-tab-${tab.label}`"
v-bind:key="tab.name"
class="content-tab text-capitalize"
:name="tab.name"
@ -272,7 +280,7 @@ const smallBanner = ref(false);
<!-- small -->
<q-img
v-if="(!$q.screen.gt.sm && smallBanner) || smallBanner"
v-if="(!$q.screen.gt.sm && smallBanner) || smallBanner || $q.screen.lt.sm"
fit="cover"
class="cover rounded bordered relative-position"
:style="`height: 45px`"
@ -332,9 +340,9 @@ const smallBanner = ref(false);
color: `${color || 'white'}`,
}"
>
<q-icon
<Icon
class="full-width full-height flex items-center justify-center"
:name="icon || 'mdi-account'"
:icon="icon || 'mdi-account'"
/>
</div>
</template>
@ -347,9 +355,9 @@ const smallBanner = ref(false);
color: `${color || 'white'}`,
}"
>
<q-icon
<Icon
class="full-width full-height flex items-center justify-center"
:name="icon || 'mdi-account'"
:icon="icon || 'mdi-account'"
/>
</div>
</template>
@ -363,9 +371,9 @@ const smallBanner = ref(false);
color: `${color || 'white'}`,
}"
>
<q-icon
<Icon
class="full-width full-height flex items-center justify-center"
:name="icon || 'mdi-account'"
:icon="icon || 'mdi-account'"
/>
</div>
@ -399,7 +407,8 @@ const smallBanner = ref(false);
</span>
<span
v-if="title"
class="app-text-muted absolute"
class="absolute"
:class="$q.dark.isActive ? 'foreground' : 'app-text-muted'"
style="font-size: 10px; bottom: 4px"
>
{{ caption }}

View file

@ -32,12 +32,14 @@ const file = defineModel<File | null>('file', {
});
const reader = new FileReader();
const inputFile = (() => {
const { inputFile, resetInputFile } = (() => {
const _form = document.createElement('form');
const _element = document.createElement('input');
_element.type = 'file';
_element.accept = 'image/*';
_element.addEventListener('change', change);
return _element;
_form.appendChild(_element);
return { inputFile: _element, resetInputFile: () => _form.reset() };
})();
reader.addEventListener('load', () => {
@ -48,6 +50,7 @@ reader.addEventListener('load', () => {
});
function browse() {
resetInputFile();
inputFile?.click();
}
@ -113,7 +116,7 @@ async function downloadImage(url: string) {
unelevated
round
v-if="!changeDisabled"
@click="inputFile?.click()"
@click="browse"
style="color: hsla(var(--stone-0-hsl) / 0.7)"
></q-btn>
<q-btn
@ -159,6 +162,7 @@ async function downloadImage(url: string) {
v-close-popup
/>
<SaveButton
:label="$t('general.confirm')"
outlined
@click="$emit('save', inputFile?.files?.[0] || null, imageUrl)"
/>

View file

@ -0,0 +1,7 @@
<script lang="ts" setup>
defineProps<{}>();
const emit = defineEmits<{}>();
</script>
<template></template>
<style scoped></style>

View file

@ -8,6 +8,7 @@ type Menu = {
anchor: string;
name: string;
sub?: boolean;
hideSubIndex?: boolean;
tab?: string;
useBtn?: boolean;
};
@ -98,10 +99,10 @@ onUnmounted(() => {
class="row no-wrap items-center"
:class="{ 'app-text-muted': v.sub && activeMenu !== v.anchor }"
>
<div v-if="v.sub" class="circle-2"></div>
<div v-if="v.sub" class="circle-2 q-mr-md"></div>
<div
v-if="v.sub"
class="surface-tab circle flex justify-center q-mx-md"
v-if="v.sub && !v.hideSubIndex"
class="surface-tab circle flex justify-center q-mr-md"
>
{{ menu.filter((v) => v.sub === true).indexOf(v) + 1 }}
</div>

View file

@ -1,4 +1,6 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue/dist/iconify.js';
const props = withDefaults(
defineProps<{
branch: {
@ -11,13 +13,16 @@ const props = withDefaults(
| 'purple'
| 'green'
| 'orange'
| 'dark-orange'
| 'cyan'
| 'yellow'
| 'red'
| 'magenta'
| 'blue'
| 'lime'
| 'light-purple';
| 'light-purple'
| 'light-green'
| 'gray';
}[];
dark?: boolean;
textSize?: string;
@ -44,8 +49,9 @@ const props = withDefaults(
size="lg"
style="background-color: hsla(0 0% 100% /0.2)"
text-color="white"
:icon="v.icon"
/>
>
<Icon :icon="v.icon" width="24px" />
</q-avatar>
</div>
<div class="col-6 justify-center column">
<div
@ -114,6 +120,10 @@ const props = withDefaults(
--_color: var(--orange-5-hsl);
}
.stat-card__dark-orange {
--_color: var(--orange-10-hsl);
}
.stat-card__magenta {
--_color: var(--pink-8-hsl);
}
@ -122,6 +132,10 @@ const props = withDefaults(
--_color: var(--jungle-8-hsl);
}
.stat-card__light-green {
--_color: var(--green-8-hsl);
}
.stat-card__light-purple {
--_color: var(--purple-7-hsl);
}
@ -130,6 +144,10 @@ const props = withDefaults(
--_color: var(--blue-6-hsl);
}
.stat-card__gray {
--_color: var(--gray-6-hsl);
}
.dark .stat-card__purple {
--_color: var(--violet-10-hsl);
}
@ -142,6 +160,10 @@ const props = withDefaults(
--_color: var(--orange-6-hsl);
}
.dark.stat-card__dark-orange {
--_color: var(--orange-11-hsl);
}
.dark .stat-card__magenta {
--_color: var(--pink-7-hsl);
}

View file

@ -1,208 +0,0 @@
<script setup lang="ts">
import { ref } from 'vue';
import { CustomerBranchCreate } from 'stores/customer/types';
import { onMounted } from 'vue';
import { checkTabBeforeAdd } from 'stores/utils';
const props = defineProps<{
readonly?: boolean;
edit?: boolean;
prefixId: string;
}>();
const customerBranch = defineModel<CustomerBranchCreate[]>('customerBranch', {
default: [],
});
const statBranchNo = defineModel<number>('statBranchNo', {
default: 0,
});
const tab = defineModel<number>('tabIndex', { required: true });
function addData() {
const canAdd = checkTabBeforeAdd(customerBranch.value || [], [
'branchNo',
'payDate',
'registerDate',
'status',
'wageRate',
]);
if (canAdd) {
const currentNo = (customerBranch.value.at(-1)?.branchNo || 0) + 1;
customerBranch.value.push({
code: '',
branchNo: currentNo,
address: '',
addressEN: '',
provinceId: '',
districtId: '',
subDistrictId: '',
zipCode: '',
email: '',
telephoneNo: '',
name: '',
status: 'CREATED',
taxNo: '',
nameEN: '',
legalPersonNo: '',
registerName: '',
registerDate: new Date(),
authorizedCapital: '',
employmentOffice: '',
bussinessType: '',
bussinessTypeEN: '',
jobPosition: '',
jobPositionEN: '',
jobDescription: '',
saleEmployee: '',
payDate: new Date(),
wageRate: 0,
});
tab.value = customerBranch.value.length - 1;
}
}
function close(index: number) {
if (customerBranch.value.length < 2) return;
if (
customerBranch.value.length === index + 1 &&
tab.value + 1 === customerBranch.value.length
) {
tab.value = tab.value - 1;
} else if (tab.value >= index) {
if (tab.value !== 0) {
tab.value = tab.value - 1;
}
}
customerBranch.value.splice(index, 1);
}
onMounted(() => {
customerBranch.value[0].branchNo =
statBranchNo.value !== 0 ? statBranchNo.value : 1;
});
</script>
<template>
<div class="row no-wrap full-width">
<q-btn
:id="`${prefixId}-btn-add`"
class="q-px-lg bordered-b bordered-r app-text-muted"
flat
:color="$q.dark.isActive ? 'primary' : ''"
style="background-color: var(--_body-bg)"
@click="addData()"
icon="mdi-plus"
:disable="
readonly ||
!checkTabBeforeAdd(customerBranch || [], [
'branchNo',
'payDate',
'registerDate',
'status',
'wageRate',
])
"
></q-btn>
<q-tabs
:active-bg-color="$q.dark.isActive ? 'dark' : 'white'"
:active-color="$q.dark.isActive ? 'white' : 'primary'"
indicator-color="transparent"
active-class="bordered-r"
dense
v-model="tab"
align="left"
inline-label
mobile-arrows
class="text-grey col"
:breakpoint="0"
style="background-color: var(--_body-bg); max-width: 55vw"
>
<q-tab
:id="`${prefixId}-tab-branch-${index}`"
v-for="(v, index) in customerBranch"
:key="index"
:name="index"
:label="`${customerBranch[index].name !== '' ? customerBranch[index].name : $t('customerBranchFormTab')} `"
:disable="tab !== index && edit"
@click="tab = index"
no-caps
:class="tab === index ? '' : 'bordered-b bordered-r'"
>
<q-btn
v-if="!readonly && customerBranch?.length !== 1"
round
flat
:id="`${prefixId}-close-tab-${index}`"
icon="mdi-close"
size="sm"
padding="xs"
color="red"
class="q-ml-sm"
:class="{ dark: $q.dark.isActive }"
@click.stop="
() => {
if (v.statusSave) {
$emit('remove', index, v.id);
} else {
close(index);
}
}
"
/>
</q-tab>
</q-tabs>
</div>
<div class="column seprarator-fix">
<q-tab-panels v-model="tab" class="rounded-borders">
<q-tab-panel
:id="`${prefixId}-tab-branch-${index}`"
v-for="(v, index) in customerBranch.sort(
(a, b) => (a.branchNo ?? 0) - (b.branchNo ?? 0),
)"
:key="index"
:name="index"
>
<slot name="about"></slot>
<slot name="address"></slot>
<slot name="businessInformation"></slot>
<slot name="contactInformation"></slot>
<slot name="otherDocuments"></slot>
<div class="row col-12 justify-end">
<q-btn
v-if="!readonly"
dense
unelevated
id="save-basic-info"
color="primary"
class="q-px-md"
:label="$t('save')"
@click="$emit('save')"
/>
</div>
</q-tab-panel>
</q-tab-panels>
</div>
</template>
<style scoped lang="scss">
.active-tab {
border: 1px solid #e0dcdc;
border-bottom: none;
border-top: none;
border-left: none;
}
.tab-style {
border: 1px solid #e0dcdc;
border-left: none;
border-top: none;
}
:deep(.q-separator) {
padding-block: 0px !important;
}
</style>

View file

@ -17,6 +17,7 @@ const props = withDefaults(
hidePagination?: boolean;
inTable?: boolean;
hideView?: boolean;
btnSelected?: boolean;
imgColumn?: string;
customColumn?: string[];
@ -55,7 +56,7 @@ defineEmits<{
>
<template v-slot:header="props">
<q-tr
:style="`background-color: ${inTable ? '#F0FFF1' : 'hsla(var(--info-bg) / 0.07'} `"
:style="`background-color: ${inTable ? 'hsla(var(--green-4-hsl) / 0.07)' : 'hsla(var(--info-bg) / 0.07'} `"
:props="props"
>
<q-th v-for="col in props.cols" :key="col.name" :props="props">

View file

@ -1,6 +1,7 @@
<script setup lang="ts">
import { BranchWithChildren } from 'stores/branch/types';
import KebabAction from './shared/KebabAction.vue';
import { isRoleInclude } from 'stores/utils';
const nodes = defineModel<(any | BranchWithChildren)[]>('nodes', {
default: [],
@ -119,7 +120,11 @@ defineEmits<{
/>
<q-btn
v-if="node.isHeadOffice && typeTree === 'branch'"
v-if="
node.isHeadOffice &&
typeTree === 'branch' &&
isRoleInclude(['head_of_admin', 'admin', 'system'])
"
:id="`create-sub-branch-btn-${node.name}`"
@click.stop="$emit('create', node)"
icon="mdi-file-plus-outline"

View file

@ -0,0 +1,73 @@
<script setup lang="ts">
import { onUnmounted } from 'vue';
import { onMounted } from 'vue';
import { ref } from 'vue';
const open = ref(false);
const element = ref<HTMLElement>();
function click({ target }: MouseEvent) {
if (!open.value) return;
if (target instanceof HTMLElement && element.value?.contains(target)) {
return;
}
open.value = false;
}
onMounted(() => {
window.addEventListener('mouseup', click);
});
onUnmounted(() => {
window.removeEventListener('mouseup', click);
});
</script>
<template>
<div class="dropdown" ref="element">
<button class="dropdown__trigger" @click.stop="open = !open">
<slot name="trigger" />
</button>
<Transition>
<div class="dropdown__content" v-if="open">
<slot name="content" />
</div>
</Transition>
</div>
</template>
<style scoped>
.dropdown {
display: inline-block;
overflow: visible;
position: relative;
& .dropdown__trigger {
appearance: none;
background: transparent;
outline: none;
border: none;
padding: 0;
}
& .dropdown__content {
position: absolute;
z-index: 9999;
top: 100%;
left: 0%;
margin-top: 0.25rem;
border: 1px solid var(--border-color);
border-radius: var(--radius-2, 7px);
}
}
.v-enter-active,
.v-leave-active {
transition: opacity 0.15s ease;
}
.v-enter-from,
.v-leave-to {
opacity: 0;
}
</style>

View file

@ -0,0 +1,29 @@
<script lang="ts" setup>
import MainButton from './MainButton.vue';
defineEmits<{
(e: 'click', v: MouseEvent): void;
}>();
defineProps<{
iconOnly?: boolean;
solid?: boolean;
outlined?: boolean;
disabled?: boolean;
dark?: boolean;
label?: string;
icon?: string;
}>();
</script>
<template>
<MainButton
@click="(e) => $emit('click', e)"
v-bind="{ ...$props, ...$attrs }"
:icon="icon || 'mdi-import'"
color="var(--info-bg)"
:title="iconOnly ? $t('general.import') : undefined"
>
{{ label || $t('general.import') }}
</MainButton>
</template>

View file

@ -5,11 +5,12 @@ defineEmits<{
(e: 'click', v: MouseEvent): void;
}>();
defineProps<{
icon: string;
icon?: string;
color: string;
iconOnly?: boolean;
solid?: boolean;
outlined?: boolean;
pill?: boolean;
disabled?: boolean;
dark?: boolean;
}>();
@ -22,6 +23,7 @@ defineProps<{
:class="{
'main-btn__solid': solid && !outlined,
'main-btn__outline': !solid && outlined,
'main-btn__pill': pill,
'main-btn__dark': dark,
'main-btn__icon-only': iconOnly,
}"
@ -30,7 +32,7 @@ defineProps<{
type="button"
>
<slot name="icon">
<Icon :icon class="main-btn-icon" />
<Icon :icon="icon || ''" class="main-btn-icon" />
</slot>
<slot v-if="!iconOnly" />
</button>
@ -39,7 +41,7 @@ defineProps<{
<style scoped>
.main-btn {
position: relative;
display: flex;
display: inline-flex;
align-items: center;
justify-content: center;
background: transparent;
@ -101,16 +103,17 @@ defineProps<{
color: hsla(var(--button-main-color) / 1);
}
.main-btn-edit:disabled {
filter: grayscale(1);
opacity: 0.8;
&.main-btn__solid {
background: hsla(0 0% 0% / 0.08);
}
.main-btn:disabled {
filter: grayscale(0.5);
opacity: 0.5 !important;
cursor: not-allowed;
&.main-btn__outline {
border: 1px solid hsla(0 0% 0% / 0.3);
border: 1px solid hsla(var(--button-main-color) / 0.3);
}
}
.main-btn__pill {
border-radius: 999rem;
}
</style>

View file

@ -0,0 +1,32 @@
<script lang="ts" setup>
import MainButton from './MainButton.vue';
defineEmits<{
(e: 'click', v: MouseEvent): void;
}>();
defineProps<{
iconOnly?: boolean;
solid?: boolean;
outlined?: boolean;
disabled?: boolean;
dark?: boolean;
label?: string;
icon?: string;
amount?: number;
}>();
</script>
<template>
<MainButton
@click="(e) => $emit('click', e)"
v-bind="{ ...$props, ...$attrs }"
:icon="icon || 'mdi-arrow-right'"
color="207 96% 32%"
:title="iconOnly ? $t('general.next') : undefined"
>
{{ label || $t('general.next') }}
{{ amount && amount > 0 ? `(${amount})` : '' }}
</MainButton>
</template>

View file

@ -0,0 +1,89 @@
<script setup lang="ts">
import { RequestWork, RequestWorkStatus } from 'src/stores/request-list/types';
withDefaults(
defineProps<{
statusDone?: boolean;
statusActive?: boolean;
statusWaiting?: boolean;
label: string;
}>(),
{
statusDone: false,
statusActive: false,
label: '',
},
);
defineEmits<{
(e: 'click'): void;
}>();
</script>
<template>
<button
class="status-color q-pa-sm bordered row items-center cursor-pointer no-wrap"
style="text-wrap: nowrap"
:class="{
['status-color-done']: statusDone,
['status-color-doing']: true,
['status-color-waiting']: statusWaiting,
['step-status-active']: statusActive,
}"
@click="statusWaiting && !statusDone ? undefined : $emit('click')"
>
<div class="q-px-sm">
<q-icon
class="icon-color quotation-status"
style="border-radius: 50%"
:name="`${statusActive ? 'mdi-circle-slice-8' : statusDone ? 'mdi-check-circle' : 'mdi-checkbox-blank-circle-outline'}`"
/>
</div>
<div class="text-left">{{ label }}</div>
</button>
</template>
<style scoped>
.status-color {
--_color: var(--gray-0);
border-color: hsla(var(--_color));
background: hsla(var(--_color) / 0.05);
border-radius: 4px;
.icon-color {
color: hsla(var(--_color));
}
&.status-color-doing {
--_color: var(--blue-5-hsl);
color: var(--foreground);
}
&.status-color-waiting:not(.status-color-done) {
--_color: var(--gray-4-hsl);
color: var(--foreground);
cursor: not-allowed !important;
}
&.status-color-done {
--_color: var(--green-5-hsl);
color: var(--foreground);
}
}
.step-status-active {
opacity: 1;
font-weight: 600;
transition: 1s box-shadow ease-in-out;
animation: status 1s infinite;
}
@keyframes status {
0% {
box-shadow: 0x 0px 0px hsla(var(--_color) / 0.5);
}
50% {
box-shadow: 0px 0px 1px 4px hsla(var(--_color) / 0.2);
}
100% {
box-shadow: 0px 0px 4px 7px hsla(var(--_color) / 0);
}
}
</style>

View file

@ -18,7 +18,7 @@ defineProps<{
@click="(e) => $emit('click', e)"
v-bind="{ ...$props, ...$attrs }"
icon="mdi-arrow-left"
color="var(--gray-8-hsl)"
:color="`var(--gray-${$q.dark.isActive ? '6' : '8'}-hsl)`"
:title="iconOnly ? $t('general.undo') : undefined"
>
{{ $t('general.undo') }}

View file

@ -10,7 +10,6 @@ defineProps<{
outlined?: boolean;
disabled?: boolean;
dark?: boolean;
size?: string;
}>();
</script>

View file

@ -11,3 +11,6 @@ export { default as ClearButton } from './ClearButton.vue';
export { default as CloseButton } from './CloseButton.vue';
export { default as ViewButton } from './ViewButton.vue';
export { default as PrintButton } from './PrintButton.vue';
export { default as StateButton } from './StateButton.vue';
export { default as NextButton } from './NextButton.vue';
export { default as ImportButton } from './ImportButton.vue';

View file

@ -40,7 +40,7 @@ const state = defineModel({ default: false });
>
<div class="column full-height">
<!-- NOTE: DIALOG HEADER -->
<div class="form-header q-py-sm q-px-md">
<div class="form-header q-py-sm q-px-md" v-if="$slots['header']">
<slot name="header" />
</div>
@ -54,7 +54,7 @@ const state = defineModel({ default: false });
<!-- NOTE: DIALOG FOOTER -->
<div
v-if="footer || $slots.footer"
v-if="footer || $slots['footer']"
class="dialog-footer row items-center full-width justify-between q-px-md q-py-sm surface-1"
>
<slot name="footer"></slot>

View file

@ -57,7 +57,7 @@ const state = defineModel({ default: false });
<!-- NOTE: DIALOG BODY -->
<div
class="col full-height column full-width"
class="col full-height column full-width surface-0"
:class="{ dark: $q.dark.isActive }"
>
<slot />

View file

@ -28,8 +28,10 @@ defineProps<{
.dialog-header-main {
flex: 1;
text-align: center;
font-weight: bolder;
display: flex;
align-items: center;
justify-content: center;
}
.dialog-header-close {

View file

@ -0,0 +1,797 @@
<script setup lang="ts">
import { ref, watch } from 'vue';
import { moveItemUp, moveItemDown, dialog, deleteItem } from 'stores/utils';
import { useI18n } from 'vue-i18n';
import useOptionStore from 'src/stores/options';
import { useWorkflowTemplate } from 'src/stores/workflow-template';
import { Option } from 'stores/options/types';
import {
WorkFlowPayloadStep,
WorkflowTemplate,
} from 'src/stores/workflow-template/types';
import SelectFlow from '../shared/select/SelectFlow.vue';
import NoData from '../NoData.vue';
import DialogForm from '../DialogForm.vue';
const { t } = useI18n();
const { getWorkflowTemplate } = useWorkflowTemplate();
const optionStore = useOptionStore();
const props = defineProps<{
stepIndex?: number;
onEdit?: boolean;
selectFlow?: boolean;
}>();
const emit = defineEmits<{
(e: 'submit', currWorkflow: WorkflowTemplate): void;
(e: 'show'): void;
}>();
const model = defineModel<boolean>({ required: true, default: false });
const workflowId = defineModel<string>('workflowId', { default: '' });
const dataStep = defineModel<WorkFlowPayloadStep[]>('dataStep', {
default: [],
});
const tempStep = ref<WorkFlowPayloadStep[]>([]);
const tempWorkflowId = ref<string>('');
const propertiesOption = ref();
const currWorkflow = ref<WorkflowTemplate>();
const typeOption = [
{
label: 'Text',
value: 'string',
color: 'var(--pink-6-hsl)',
icon: 'mdi-alpha-t',
},
{
label: 'Number',
value: 'number',
color: 'var(--purple-11-hsl)',
icon: 'mdi-numeric',
},
{
label: 'Date',
value: 'date',
color: 'var(--green-9-hsl)',
icon: 'mdi-calendar-blank-outline',
},
{
label: 'Selection',
value: 'array',
color: 'var(--indigo-7-hsl)',
icon: 'mdi-code-array',
},
];
function submit() {
workflowId.value = tempWorkflowId.value;
dataStep.value = JSON.parse(JSON.stringify(tempStep.value));
model.value = false;
if (props.selectFlow && currWorkflow.value) {
emit('submit', currWorkflow.value);
}
}
function close() {
tempStep.value = [];
model.value = false;
}
function manageProperties(
stepIndex: number,
property: string,
propertyType?: 'date' | 'array' | 'string' | 'number',
) {
if (property === 'all' && propertiesOption.value) {
if (
tempStep.value[stepIndex].attributes.properties.length ===
propertiesOption.value.length
) {
tempStep.value[stepIndex].attributes.properties = [];
return;
}
for (const ops of propertiesOption.value) {
if (
tempStep.value[stepIndex].attributes.properties.some(
(prop) => prop.fieldName === ops.value,
)
) {
continue;
}
if (ops.type === 'date') {
tempStep.value[stepIndex].attributes.properties.push({
type: ops.type,
fieldName: ops.value,
});
}
if (ops.type === 'array') {
tempStep.value[stepIndex].attributes.properties.push({
type: ops.type,
fieldName: ops.value,
options: [],
});
}
if (ops.type === 'string') {
tempStep.value[stepIndex].attributes.properties.push({
type: ops.type,
fieldName: ops.value,
isPhoneNumber: false,
phoneNumberLength: 10,
});
}
if (ops.type === 'number') {
tempStep.value[stepIndex].attributes.properties.push({
type: ops.type,
fieldName: ops.value,
comma: false,
decimal: false,
decimalPlace: 2,
});
}
}
return;
}
if (tempStep.value[stepIndex].attributes.properties) {
const propStep = tempStep.value[stepIndex].attributes.properties.findIndex(
(prop) => prop.fieldName === property,
);
if (propStep !== -1) {
tempStep.value[stepIndex].attributes.properties.splice(propStep, 1);
} else {
if (propertyType === 'date') {
tempStep.value[stepIndex].attributes.properties.push({
type: propertyType,
fieldName: property,
});
}
if (propertyType === 'array') {
tempStep.value[stepIndex].attributes.properties.push({
type: propertyType,
fieldName: property,
options: [],
});
}
if (propertyType === 'string') {
tempStep.value[stepIndex].attributes.properties.push({
type: propertyType,
fieldName: property,
isPhoneNumber: false,
phoneNumberLength: 10,
});
}
if (propertyType === 'number') {
tempStep.value[stepIndex].attributes.properties.push({
type: propertyType,
fieldName: property,
comma: false,
decimal: false,
decimalPlace: 2,
});
}
}
}
}
function changeType(fieldName: string, stepIndex: number) {
if (!propertiesOption.value) return;
const defaultPropType = propertiesOption.value.find(
(op: { value: string }) => op.value === fieldName,
)?.type;
if (!defaultPropType) return;
const idx = tempStep.value[stepIndex].attributes.properties.findIndex(
(p) => p.fieldName === fieldName,
);
if (!idx) return;
if (defaultPropType === 'date') {
tempStep.value[stepIndex].attributes.properties.push({
type: defaultPropType,
fieldName,
});
}
if (defaultPropType === 'array') {
tempStep.value[stepIndex].attributes.properties.push({
type: defaultPropType,
fieldName,
options: [],
});
}
if (defaultPropType === 'string') {
tempStep.value[stepIndex].attributes.properties.push({
type: defaultPropType,
fieldName,
isPhoneNumber: false,
phoneNumberLength: 10,
});
}
if (defaultPropType === 'number') {
tempStep.value[stepIndex].attributes.properties.push({
type: defaultPropType,
fieldName,
comma: false,
decimal: false,
decimalPlace: 2,
});
}
}
function shouldShowItem(opt: Option, stepIndex: number) {
if (tempStep.value[stepIndex].attributes.properties) {
const properties = new Set(
tempStep.value[stepIndex].attributes.properties.map((p) => p.fieldName),
);
return !!opt && !properties.has(opt.value);
}
}
function confirmDelete(items: unknown[], index: number) {
dialog({
color: 'negative',
icon: 'mdi-alert',
title: t('dialog.title.confirmDelete'),
actionText: t('general.delete'),
message: t('dialog.message.confirmDelete'),
action: async () => {
deleteItem(items, index);
},
cancel: () => {},
});
}
function assignTemp() {
propertiesOption.value = optionStore.globalOption?.servicePropertiesField;
tempStep.value = JSON.parse(JSON.stringify(dataStep.value));
tempWorkflowId.value = workflowId.value;
}
watch(
() => tempWorkflowId.value,
async (a, b) => {
if (props.onEdit && workflowId.value === a) return;
if (props.selectFlow && a !== b && a) {
const ret = await getWorkflowTemplate(a);
if (ret) {
currWorkflow.value = JSON.parse(JSON.stringify(ret));
tempStep.value =
ret.step?.length > 0
? ret.step.map((s) => ({
name: s.name,
attributes: s.attributes,
}))
: [];
}
}
},
);
watch(
() => model.value,
() => {
if (model.value) {
assignTemp();
}
},
);
</script>
<template>
<DialogForm
no-address
no-app-box
height="60vh"
width="75%"
:title="$t('general.properties')"
v-model:modal="model"
:submit="submit"
:close="close"
:show="() => $emit('show')"
>
<div class="column full-height no-wrap">
<div
v-if="selectFlow"
class="bordered-b surface-3 row items-center no-wrap q-py-sm"
:class="{
'q-px-lg': $q.screen.gt.sm,
'q-px-md': !$q.screen.gt.sm,
}"
>
{{ $t('flow.title') }}
<SelectFlow
style="width: 18vw"
class="q-ml-sm"
v-model:value="tempWorkflowId"
:label="$t('flow.title')"
simple
/>
</div>
<template v-if="$slots.prepend">
<slot name="prepend"></slot>
</template>
<div
v-if="tempStep?.length === 0"
class="row surface-1 rounded bordered items-center justify-center col"
:class="{
'q-ma-lg': $q.screen.gt.sm,
'q-ma-md': !$q.screen.gt.sm,
}"
>
<NoData :text="$t('general.no', { msg: $t('flow.processStep') })" />
</div>
<section
v-for="(step, stepIndex) in tempStep"
:key="stepIndex"
class="column"
>
<template
v-if="
(props.stepIndex !== undefined && stepIndex === props.stepIndex) ||
props.stepIndex === undefined
"
>
<span
class="row items-center q-py-sm bordered-b"
:class="{
'q-px-lg': $q.screen.gt.sm,
'q-px-md ': !$q.screen.gt.sm,
}"
>
{{ $t('flow.stepNo', { msg: (props.stepIndex || stepIndex) + 1 }) }}
<span class="app-text-muted">: {{ step.name }}</span>
<q-btn-dropdown
unelevated
no-icon-animation
size="sm"
padding="0 0"
class="rounded q-ml-md"
dropdown-icon="mdi-plus"
style="color: hsl(var(--text-mute))"
>
<q-list dense v-if="propertiesOption">
<q-item
for="list-all"
id="list-all"
clickable
@click="manageProperties(stepIndex, 'all')"
>
<div class="full-width flex items-center">
<q-icon
v-if="
tempStep[stepIndex].attributes.properties?.length ===
propertiesOption?.length
"
name="mdi-checkbox-marked"
size="xs"
class="q-mr-sm"
color="primary"
/>
<q-icon
v-else
name="mdi-checkbox-blank-outline"
size="xs"
class="q-mr-sm"
style="color: hsl(var(--text-mute))"
/>
{{ $t('general.selectAll') }}
</div>
</q-item>
<q-separator />
<q-item
v-for="(ops, index) in propertiesOption"
clickable
:key="index"
@click="manageProperties(stepIndex, ops.value, ops.type)"
:for="`list-${ops.value}`"
:id="`list-${ops.value}`"
>
<div class="full-width flex items-center no-wrap">
<q-icon
v-if="
tempStep[stepIndex].attributes.properties.some(
(p) => p.fieldName === ops.value,
)
"
name="mdi-checkbox-marked"
size="xs"
color="primary"
class="q-mr-sm"
/>
<q-icon
v-else
name="mdi-checkbox-blank-outline"
size="xs"
style="color: hsl(var(--text-mute))"
class="q-mr-sm"
/>
{{ ops.label }}
</div>
</q-item>
</q-list>
</q-btn-dropdown>
</span>
<div
v-if="tempStep[stepIndex].attributes?.properties?.length === 0"
class="row surface-1 rounded bordered items-center justify-center col"
:class="{
'q-ma-lg': $q.screen.gt.sm,
'q-ma-md': !$q.screen.gt.sm,
}"
>
<NoData useField />
</div>
<div
v-if="tempStep[stepIndex].attributes?.properties?.length > 0"
class="q-py-sm"
:class="{
'q-px-lg': $q.screen.gt.sm,
'q-px-md': !$q.screen.gt.sm,
}"
>
<div
v-for="(prop, propIndex) in tempStep[stepIndex].attributes
.properties"
:key="propIndex"
class="bordered surface-1 rounded q-py-sm q-px-md row items-start q-my-sm"
>
<div class="col-md col-12 row items-center">
<q-btn
id="btn-move-up-product"
icon="mdi-arrow-up"
dense
flat
round
:disable="propIndex === 0"
:size="$q.screen.xs ? 'xs' : ''"
style="color: hsl(var(--text-mute-2))"
@click="
moveItemUp(
tempStep[stepIndex].attributes.properties,
propIndex,
)
"
/>
<q-btn
id="btn-move-down-product"
icon="mdi-arrow-down"
dense
flat
round
:size="$q.screen.xs ? 'xs' : ''"
:disable="
propIndex ===
tempStep[stepIndex].attributes.properties?.length - 1
"
style="color: hsl(var(--text-mute-2))"
@click="
moveItemDown(
tempStep[stepIndex].attributes.properties,
propIndex,
)
"
/>
<q-avatar
:size="$q.screen.xs ? 'sm' : 'md'"
class="q-mx-lg"
style="background-color: var(--surface-3)"
>
{{ propIndex + 1 }}
</q-avatar>
<!-- field name -->
<q-select
dense
outlined
emit-value
map-options
hide-bottom-space
for="input-properties-name"
class="col-md col-12 q-mr-md"
:class="{ 'q-my-sm': $q.screen.lt.md }"
:label="$t('productService.service.propertiesName')"
option-label="label"
option-value="value"
:options="propertiesOption"
v-model="prop.fieldName"
@update:model-value="(v) => changeType(v, stepIndex)"
>
<template v-slot:option="scope">
<q-item
v-if="scope.opt && shouldShowItem(scope.opt, stepIndex)"
v-bind="scope.itemProps"
class="row items-center col-12"
>
{{ scope.opt.label }}
</q-item>
</template>
</q-select>
</div>
<!-- type -->
<div
v-if="
prop.fieldName === 'documentCheck' ||
prop.fieldName === 'designForm' ||
prop.fieldName === 'messenger' ||
prop.fieldName === 'duty'
"
class="col-md col-12"
></div>
<div v-else class="col-md col-12">
<q-select
dense
outlined
emit-value
map-options
hide-bottom-space
for="input-properties-type"
id="input-properties-type"
:label="$t('general.type')"
option-value="value"
@update:model-value="
(t: 'string' | 'number' | 'date' | 'array') => {
if (!tempStep) return;
if (t === 'date') {
tempStep[stepIndex].attributes.properties[propIndex] = {
type: t,
fieldName: prop.fieldName,
};
}
if (t === 'array') {
tempStep[stepIndex].attributes.properties[propIndex] = {
type: t,
fieldName: prop.fieldName,
options: [],
};
}
if (t === 'string') {
tempStep[stepIndex].attributes.properties[propIndex] = {
type: t,
fieldName: prop.fieldName,
isPhoneNumber: false,
phoneNumberLength: 10,
};
}
if (t === 'number') {
tempStep[stepIndex].attributes.properties[propIndex] = {
type: t,
fieldName: prop.fieldName,
comma: false,
decimal: false,
decimalPlace: 2,
};
}
}
"
:options="typeOption"
v-model="prop.type"
>
<template v-slot:option="scope">
<q-item
v-if="scope.opt"
v-bind="scope.itemProps"
class="row items-center col-12"
:id="`type-${scope.itemProps}`"
>
<q-avatar
size="sm"
class="q-mr-md"
:style="`background-color: hsla(${scope.opt.color}/0.2)`"
>
<q-icon
size="20px"
:name="scope.opt.icon"
:style="`color: hsl(${scope.opt.color})`"
/>
</q-avatar>
{{ scope.opt.label }}
</q-item>
</template>
<template v-slot:selected-item="scope">
<div v-if="scope.opt" class="row items-center col-12">
<q-avatar
size="xs"
class="q-mr-sm"
:style="`background-color: hsla(${scope.opt.color}/0.2)`"
>
<q-icon
size="14px"
:name="scope.opt.icon"
:style="`color: hsl(${scope.opt.color})`"
/>
</q-avatar>
{{ scope.opt.label }}
</div>
</template>
</q-select>
<div v-if="prop.type !== 'date' && prop.type">
<q-item class="no-padding" style="font-size: 11px">
<q-item-section
class="column q-py-sm"
:style="{ 'padding-left: 12px': $q.screen.gt.xs }"
>
<span class="app-text-muted-2">
{{ $t('general.additional') }}
</span>
<div v-if="prop.type === 'string'" class="q-gutter-y-sm">
<div class="row items-center">
<div class="col-7 surface-3 rounded q-mr-sm q-py-xs">
<q-checkbox
:for="`checkbox-is-phone-number-${prop.fieldName}`"
v-if="prop.type === 'string'"
v-model="prop.isPhoneNumber"
size="xs"
/>
{{ $t('general.telephone') }}
</div>
<q-input
v-if="prop.type === 'string'"
:for="`input-max-length-${prop.fieldName}`"
v-model="prop.phoneNumberLength"
input-class="text-caption"
class="col additional-label"
dense
outlined
:label="$t('form.maxLength')"
/>
</div>
</div>
<div v-if="prop.type === 'number'" class="q-gutter-y-sm">
<div class="row items-center">
<div class="col-md-4 col-12 surface-3 rounded">
<q-checkbox
v-model="prop.comma"
size="xs"
class="q-py-xs"
:for="`checkbox-is-comma-${prop.fieldName}`"
/>
{{ $t('form.useComma') }}
</div>
<div
class="col-md-4 col-7 surface-3 rounded"
:class="{
'q-mx-sm': $q.screen.gt.sm,
'q-mr-sm q-mt-xs': $q.screen.lt.md,
}"
>
<q-checkbox
v-model="prop.decimal"
size="xs"
class="q-py-xs"
:for="`checkbox-is-decimal-${prop.fieldName}`"
/>
{{ $t('form.decimal') }}
</div>
<q-input
:for="`input-decimal-place-${prop.fieldName}`"
v-model="prop.decimalPlace"
class="col additional-label"
:class="{ 'q-mt-xs': $q.screen.lt.md }"
input-class="text-caption"
dense
outlined
:label="$t('form.decimalPlace')"
/>
</div>
</div>
<div v-if="prop.type === 'array'" class="q-gutter-y-sm">
<div
class="row items-center justify-between"
v-for="(_, i) in prop.options"
:key="i"
>
<div class="col rounded">
<q-input
v-model="prop.options[i]"
:for="`input-selection-${prop.fieldName}-${i}`"
class="col additional-label"
dense
outlined
input-class="text-caption"
:label="$t('form.selection')"
:rules="[
(val) => !!val || $t('form.error.required'),
]"
hide-bottom-space
/>
</div>
<div class="col-1 q-pl-sm">
<q-btn
:id="`btn-delete-selection-${prop.fieldName}-${i}`"
:for="`btn-delete-selection-${prop.fieldName}-${i}`"
@click="
() => {
prop.options.splice(i, 1);
}
"
dense
flat
icon="mdi-trash-can-outline"
class="bordered"
text-color="negative"
style="border-radius: 6px"
/>
</div>
</div>
<div class="row">
<q-btn
:for="`btn-add-selection-${prop.fieldName}`"
:id="`btn-add-selection-${prop.fieldName}`"
@click="
() => {
prop.options.push('');
}
"
dense
flat
icon="mdi-plus"
class="bordered col-11"
text-color="grey"
style="border-radius: 6px"
/>
</div>
</div>
</q-item-section>
</q-item>
</div>
</div>
<q-btn
id="btn-delete-work-product"
icon="mdi-trash-can-outline"
dense
flat
round
color="negative"
class="q-ml-sm"
@click="
confirmDelete(
tempStep[stepIndex].attributes.properties,
propIndex,
)
"
/>
</div>
</div>
</template>
</section>
</div>
</DialogForm>
</template>
<style scoped></style>

View file

@ -0,0 +1,191 @@
<script setup lang="ts" generic="T extends { id: string }">
import { Ref } from 'vue';
import { reactive, ref, watch } from 'vue';
import { CancelButton, MainButton } from 'components/button';
import DialogContainer from './DialogContainer.vue';
import DialogHeader from './DialogHeader.vue';
const props = defineProps<{
getList: (search?: string) => T[] | Promise<T[]>;
dialog: {
title: string;
};
orderHidden?: boolean;
preselectedItem?: T[];
disabledItemId?: T['id'][];
/** @returns true - to auto close dialog */
onSubmit: (selected: T[]) => void | boolean | Promise<void | boolean>;
}>();
const open = defineModel<boolean>('open', { default: false });
const list: Ref<T[]> = ref([]);
const selected: Ref<T[]> = ref([]);
const state = reactive({
search: '',
});
function clean() {
list.value = [];
selected.value = [];
open.value = false;
}
function selectedIndex(item: T) {
return selected.value.findIndex((v) => v.id === item.id);
}
function toggleSelect(item: T) {
if (props.disabledItemId?.some((id) => id === item.id)) return;
const index = selectedIndex(item);
if (index === -1) {
selected.value.push(item);
} else {
selected.value.splice(index, 1);
}
}
function init() {
if (props.preselectedItem) {
selected.value = JSON.parse(JSON.stringify(props.preselectedItem));
}
getList();
}
async function getList() {
list.value = await props.getList(state.search);
}
async function submit() {
const result = await props.onSubmit(selected.value);
if (typeof result === 'boolean') open.value = !result;
}
watch(() => state.search, getList);
</script>
<template>
<DialogContainer v-model="open" @open="init" @close="clean">
<template #header>
<DialogHeader :title="dialog.title">
<template #title-before>
<span class="q-mr-auto"></span>
</template>
<template #title-after>
<span class="q-ml-auto" v-if="!$slots['title-after']" />
<div class="q-ml-auto" v-else><slot name="title-after" /></div>
</template>
</DialogHeader>
</template>
<div class="column q-pa-md full-height">
<section class="row justify-end q-mb-md">
<q-input
for="input-search"
outlined
dense
:label="$t('general.search')"
:bg-color="$q.dark.isActive ? 'dark' : 'white'"
v-model="state.search"
debounce="500"
>
<template #prepend>
<q-icon name="mdi-magnify" />
</template>
</q-input>
</section>
<!-- NOTE: wrapper -->
<div class="col scroll">
<section
:class="{ ['items-center']: list.length === 0 }"
class="row q-col-gutter-md full-height"
>
<div v-if="list.length === 0" class="inline-block q-mx-auto">
<slot name="empty" :data="{ notFound: !!state.search }" />
</div>
<div
:key="item.data.id"
v-for="(item, index) in list.map((data) => ({
data: data,
selectedIndex: selectedIndex(data),
}))"
class="col-2"
>
<button
class="selectable-item full-width"
:class="{
['selectable-item__selected']: item.selectedIndex !== -1,
['selectable-item__disabled']: disabledItemId?.some(
(id) => id === item.data.id,
),
}"
@click="toggleSelect(item.data)"
>
<span class="selectable-item__pos" v-if="!orderHidden">
{{ item.selectedIndex + 1 }}
</span>
<slot name="card" :data="{ index, item }" />
</button>
</div>
</section>
</div>
</div>
<template #footer>
<div class="q-gutter-x-xs q-ml-auto">
<CancelButton outlined @click="clean" />
<MainButton icon="mdi-check" color="207 96% 32%" solid @click="submit">
{{ $t('general.select') }}
</MainButton>
</div>
</template>
</DialogContainer>
</template>
<style scoped>
.selectable-item {
padding: 0;
appearance: none;
border: none;
background: transparent;
position: relative;
color: inherit;
& > .selectable-item__pos {
display: none;
}
}
.selectable-item__selected {
& > :deep(*) {
border: 1px solid var(--_color, var(--brand-1)) !important;
}
& > .selectable-item__pos {
display: block;
position: absolute;
margin: var(--size-2);
right: 0;
top: 0;
border-radius: 50%;
width: 20px;
height: 20px;
color: var(--surface-1);
background: var(--brand-1);
color: white;
}
}
.selectable-item__disabled {
filter: grayscale(1);
opacity: 0.5;
& :deep(*) {
cursor: not-allowed;
}
}
</style>

View file

@ -0,0 +1,99 @@
<script lang="ts" setup>
import { reactive } from 'vue';
import DialogFormContainer from './DialogFormContainer.vue';
import DialogHeader from './DialogHeader.vue';
import MainButton from '../button/MainButton.vue';
import NoData from '../NoData.vue';
defineProps<{
title: string;
url?: string;
}>();
const open = defineModel<boolean>({ default: false });
const state = reactive({
imageZoom: 100,
});
function openDialog() {
state.imageZoom = 100;
}
</script>
<template>
<DialogFormContainer v-model="open" v-on:open="openDialog">
<template #header>
<DialogHeader :title="title" />
</template>
<main class="column full-height">
<section
style="background: var(--gray-3)"
class="q-py-sm row justify-center"
>
<div class="surface-2 q-px-md q-py-sm rounded row no-wrap items-center">
<MainButton
icon="mdi-minus"
color="0 0% 0%"
icon-only
@click="
() => {
if (state.imageZoom > 0) state.imageZoom -= 10;
}
"
/>
<q-input
dense
outlined
class="q-px-sm"
input-class="text-center text-caption"
:model-value="state.imageZoom"
@update:model-value="
(val) => {
const numVal = Number(val);
if (numVal > 500 || numVal < 0) {
state.imageZoom = 100;
} else {
state.imageZoom = numVal || 100;
}
}
"
></q-input>
<MainButton
icon="mdi-plus"
color="0 0% 0%"
icon-only
@click="
() => {
if (state.imageZoom < 500) state.imageZoom += 10;
}
"
/>
</div>
</section>
<div
:class="{ 'cursor-pointer': state.imageZoom > 100 }"
class="full-height full-width flex justify-center items-center col scroll q-pa-md"
v-dragscroll
>
<q-img
v-if="url"
class="full-height"
:src="url"
fit="contain"
:style="{ transform: `scale(${state.imageZoom / 100})` }"
style="transform-origin: 0 0"
/>
<NoData v-else />
</div>
</main>
</DialogFormContainer>
</template>
<style scoped>
:deep(.q-field__control.relative-position.row.no-wrap) {
width: 60px;
height: 30px;
}
</style>

View file

@ -1,14 +1,17 @@
<script setup lang="ts">
import { onMounted, watch, reactive, ref, computed } from 'vue';
import { onMounted, watch, reactive, ref, computed, watchEffect } from 'vue';
import useAddressStore, {
District,
Province,
SubDistrict,
Office,
} from 'stores/address';
import { selectFilterOptionRefMod } from 'stores/utils';
import { QSelect } from 'quasar';
import { useI18n } from 'vue-i18n';
import { formatAddress } from 'src/utils/address';
import useOptionStore from 'stores/options';
const optionStore = useOptionStore();
defineProps<{
title?: string;
addressTitle?: string;
@ -29,19 +32,27 @@ defineProps<{
useWorkPlace?: boolean;
}>();
const adrressStore = useAddressStore();
const addressStore = useAddressStore();
const workplace = defineModel<string>('workplace', { default: '' });
const workplaceEN = defineModel<string>('workplaceEn', { default: '' });
const address = defineModel('address', { default: '' });
const addressEN = defineModel('addressEn', { default: '' });
const street = defineModel('street', { default: '' });
const streetEN = defineModel('streetEn', { default: '' });
const moo = defineModel('moo', { default: '' });
const mooEN = defineModel('mooEn', { default: '' });
const soi = defineModel('soi', { default: '' });
const soiEN = defineModel('soiEn', { default: '' });
const provinceId = defineModel<string | null | undefined>('provinceId');
const districtId = defineModel<string | null | undefined>('districtId');
const street = defineModel<string | null | undefined>('street', {
default: '',
});
const streetEN = defineModel<string | null | undefined>('streetEn', {
default: '',
});
const moo = defineModel<string | null | undefined>('moo', { default: '' });
const mooEN = defineModel<string | null | undefined>('mooEn', { default: '' });
const soi = defineModel<string | null | undefined>('soi', { default: '' });
const soiEN = defineModel<string | null | undefined>('soiEn', { default: '' });
const provinceId = defineModel<string | null | undefined>('provinceId', {
default: '',
});
const districtId = defineModel<string | null | undefined>('districtId', {
default: '',
});
const subDistrictId = defineModel<string | null | undefined>('subDistrictId');
const zipCode = defineModel<string | null | undefined>('zipCode');
const sameWithEmployer = defineModel<boolean>('sameWithEmployer');
@ -64,82 +75,63 @@ const addrOptions = reactive<{
subDistrictOps: [],
});
const { t } = useI18n();
const area = ref<Office[]>([]);
const fullAddress = computed(() => {
const addressParts = [`${address.value},`];
const province = provinceOptions.value.find((v) => v.id === provinceId.value);
const district = districtOptions.value.find((v) => v.id === districtId.value);
const sDistrict = subDistrictOptions.value.find(
(v) => v.id === subDistrictId.value,
);
if (moo.value) addressParts.push(`${t('form.moo')} ${moo.value},`);
if (soi.value) addressParts.push(`${t('form.soi')} ${soi.value},`);
if (street.value) addressParts.push(`${t('form.road')} ${street.value},`);
if (subDistrictId.value && sDistrict) {
addressParts.push(
typeof sDistrict.name === 'string' ? `${sDistrict.name},` : '',
);
if (province && district && sDistrict) {
const fullAddress = formatAddress({
address: address.value,
addressEN: addressEN.value,
moo: moo.value ? moo.value : '',
mooEN: mooEN.value ? mooEN.value : '',
soi: soi.value ? soi.value : '',
soiEN: soiEN.value ? soiEN.value : '',
street: street.value ? street.value : '',
streetEN: streetEN.value ? streetEN.value : '',
province: province as unknown as Province,
district: district as unknown as District,
subDistrict: sDistrict as unknown as SubDistrict,
});
return fullAddress;
}
if (districtId.value && district)
addressParts.push(
typeof district.name === 'string' ? `${district.name},` : '',
);
if (provinceId.value && province) {
addressParts.push(
typeof province.name === 'string' ? `${province.name}` : '',
);
sDistrict &&
addressParts.push(
typeof sDistrict.zipCode === 'string' ? `${sDistrict.zipCode}` : '',
);
}
return addressParts.join(' ');
return '-';
});
const fullAddressEN = computed(() => {
const addressParts = [`${addressEN.value},`];
const province = provinceOptions.value.find((v) => v.id === provinceId.value);
const district = districtOptions.value.find((v) => v.id === districtId.value);
const sDistrict = subDistrictOptions.value.find(
(v) => v.id === subDistrictId.value,
);
if (mooEN.value) addressParts.push(`Moo ${mooEN.value},`);
if (soiEN.value) addressParts.push(`Soi ${soiEN.value},`);
if (streetEN.value) addressParts.push(`${streetEN.value} Rd.`);
if (subDistrictId.value && sDistrict) {
addressParts.push(
typeof sDistrict.nameEN === 'string' ? `${sDistrict.nameEN},` : '',
);
if (province && district && sDistrict) {
const fullAddress = formatAddress({
address: address.value,
addressEN: addressEN.value,
moo: moo.value ? moo.value : '',
mooEN: mooEN.value ? mooEN.value : '',
soi: soi.value ? soi.value : '',
soiEN: soiEN.value ? soiEN.value : '',
street: street.value ? street.value : '',
streetEN: streetEN.value ? streetEN.value : '',
province: province as unknown as Province,
district: district as unknown as District,
subDistrict: sDistrict as unknown as SubDistrict,
en: true,
});
return fullAddress;
}
if (districtId.value && district)
addressParts.push(
typeof district.nameEN === 'string' ? `${district.nameEN},` : '',
);
if (provinceId.value && province) {
addressParts.push(
typeof province.nameEN === 'string' ? `${province.nameEN}` : '',
);
sDistrict &&
addressParts.push(
typeof sDistrict.zipCode === 'string' ? `${sDistrict.zipCode}` : '',
);
}
return addressParts.join(' ');
return '-';
});
async function fetchProvince() {
const result = await adrressStore.fetchProvince();
const result = await addressStore.fetchProvince();
if (result) addrOptions.provinceOps = result;
@ -159,7 +151,7 @@ async function fetchProvince() {
async function fetchDistrict() {
if (!provinceId.value) return;
const result = await adrressStore.fetchDistrictByProvinceId(provinceId.value);
const result = await addressStore.fetchDistrictByProvinceId(provinceId.value);
if (result) addrOptions.districtOps = result;
districtFilter = selectFilterOptionRefMod(
@ -177,7 +169,7 @@ async function fetchDistrict() {
async function fetchSubDistrict() {
if (!districtId.value) return;
const result = await adrressStore.fetchSubDistrictByProvinceId(
const result = await addressStore.fetchSubDistrictByProvinceId(
districtId.value,
);
if (result) addrOptions.subDistrictOps = result;
@ -203,7 +195,24 @@ async function selectSubDistrict(id: string) {
.map((x) => x.zipCode)[0] ?? '';
}
async function getOfficeName(districtId: string) {
const result = await addressStore.fetchOffice({
districtId,
});
if (result) return result;
return [];
}
const office = computed(() => {
if (area.value[0]) return area.value[0].name;
});
const officeEn = computed(() => {
if (area.value[0]) return area.value[0].nameEN;
});
const provinceOptions = ref<Record<string, unknown>[]>([]);
let provinceFilter: (
value: string,
update: (callbackFn: () => void, afterFn?: (ref: QSelect) => void) => void,
@ -233,7 +242,14 @@ let subDistrictEnFilter: (
update: (callbackFn: () => void, afterFn?: (ref: QSelect) => void) => void,
) => void;
const rawOption = ref();
const areaENOption = ref([]);
onMounted(async () => {
const resultOption = await fetch('/option/option.json');
rawOption.value = await resultOption.json();
areaENOption.value = rawOption.value.eng.area;
await fetchProvince();
await fetchDistrict();
await fetchSubDistrict();
@ -241,6 +257,15 @@ onMounted(async () => {
watch(provinceId, fetchDistrict);
watch(districtId, fetchSubDistrict);
watchEffect(async () => {
if (!districtId.value) {
area.value = [];
return;
}
area.value = await getOfficeName(districtId.value);
});
</script>
<template>
<div class="col-12">
@ -306,8 +331,8 @@ watch(districtId, fetchSubDistrict);
disabledRule
? []
: [
(val) => (val && val.length > 0) || $t('form.error.required'),
(val) =>
!val ||
(val && val.length === 11 && /[0-9]+/.test(val)) ||
$t('form.error.invalidCustomeMessage', {
msg: $t('form.error.requireLength', { msg: 11 }),
@ -315,25 +340,34 @@ watch(districtId, fetchSubDistrict);
]
"
/>
<q-input
outlined
hide-bottom-space
class="col"
v-model="employmentOffice"
:model-value="office"
:dense="dense"
:label="$t('customer.form.employmentOffice')"
:readonly="readonly || sameWithEmployer"
readonly
:disable="!readonly && !sameWithEmployer"
:for="`${prefixId}-${indexId !== undefined ? `input-address-${indexId}` : 'input-address'}`"
@update:model-value="
(v) => (typeof v === 'string' ? (employmentOffice = v) : '')
"
/>
<q-input
outlined
hide-bottom-space
class="col"
v-model="employmentOfficeEN"
:model-value="officeEn"
:dense="dense"
:label="`${$t('customer.form.employmentOffice')} (EN)`"
:readonly="readonly || sameWithEmployer"
readonly
:disable="!readonly && !sameWithEmployer"
:for="`${prefixId}-${indexId !== undefined ? `input-address-${indexId}` : 'input-address'}`"
@update:model-value="
(v) => (typeof v === 'string' ? (employmentOfficeEN = v) : '')
"
/>
</div>

View file

@ -1,6 +1,5 @@
export { default as AddButton } from './AddButton.vue';
export { default as AllAroundBtn } from './AllAroundBtn.vue';
export { default as ButtonAddCompoent } from './ButtonAddCompoent.vue';
export { default as FloatingActionButton } from './FloatingActionButton.vue';
export { default as CanvasComponent } from './CanvasComponent.vue';
export { default as DialogForm } from './DialogForm.vue';
export { default as DrawerInfo } from './DrawerInfo.vue';
@ -15,6 +14,5 @@ export { default as ProfileBanner } from './ProfileBanner.vue';
export { default as ProfileUpload } from './ProfileUpload.vue';
export { default as SideMenu } from './SideMenu.vue';
export { default as StatCardComponent } from './StatCardComponent.vue';
export { default as TabComponent } from './TabComponent.vue';
export { default as TooltipComponent } from './TooltipComponent.vue';
export { default as TreeComponent } from './TreeComponent.vue';

View file

@ -0,0 +1,76 @@
<script setup lang="ts">
withDefaults(
defineProps<{
data?: Record<string, unknown>[];
dataLabel?: string;
dataUrl?: string;
}>(),
{
dataLabel: 'name',
dataUrl: 'imgUrl',
data: () => [],
},
);
</script>
<template>
<div class="avatar-group">
<div class="avatar" v-for="(person, i) in data.slice(0, 3)" :key="i">
<q-tooltip>
{{ person[dataLabel] }}
</q-tooltip>
<img
:src="
typeof person[dataUrl] === 'string' ? (person[dataUrl] as string) : ''
"
alt="Image"
/>
</div>
<div v-if="data.length > 3" class="avatar remaining-count">
<q-tooltip>
<div v-for="(person, i) in data.slice(3)" :key="i + 3">
{{ person.name }}
</div>
</q-tooltip>
<span>{{ `+${data.length - 3}` }}</span>
</div>
</div>
</template>
<style scoped>
.avatar-group {
display: flex;
align-items: center;
}
.avatar {
position: relative;
transition: 0.2s;
}
.avatar:not(:first-child) {
margin-left: -0.75rem;
}
.avatar:hover {
z-index: 1;
transform: translateY(-0.5rem);
}
.avatar img {
width: 30px;
height: 30px;
display: block;
object-fit: cover;
border-radius: 50%;
border: 2px solid var(--border-color);
}
.remaining-count {
color: hsl(var(--text-mute-2));
display: flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
background-color: var(--surface-2);
border: 2px solid var(--border-color);
border-radius: 50%;
font-size: 0.8rem;
margin-left: -0.75rem;
}
</style>

View file

@ -9,6 +9,7 @@ const model = defineModel<string | Date | null>();
const props = defineProps<{
id?: string;
readonly?: boolean;
disabled?: boolean;
clearable?: boolean;
label?: string;
bgColor?: string;
@ -24,7 +25,7 @@ function valueUpdate(value: string) {
}
if (value.length === 10) {
const _date = parseAndFormatDate(value, i18n.locale.value);
const _date = parseAndFormatDate(value);
if (_date) {
if (Array.isArray(props.disabledDates)) {
@ -59,7 +60,7 @@ function valueUpdate(value: string) {
auto-apply
:id
:for="id"
:disabled="readonly"
:disabled="readonly || disabled"
:disabled-dates="disabledDates"
:teleport="true"
:dark="$q.dark.isActive"
@ -78,7 +79,8 @@ function valueUpdate(value: string) {
:rules
:label
:for="id"
:readonly="readonly"
:readonly="readonly || disabled"
:disable="disabled"
:mask="readonly ? '' : '##/##/####'"
:model-value="
model

View file

@ -6,12 +6,16 @@ import { watch } from 'vue';
const props = withDefaults(
defineProps<{
idName: string;
status: string;
idName?: string;
status?: string;
hideToggle?: boolean;
hideEdit?: boolean;
hideView?: boolean;
hideDelete?: boolean;
useLink?: boolean;
useUpload?: boolean;
useCancel?: boolean;
disableCancel?: boolean;
disableDelete?: boolean;
}>(),
{
@ -26,6 +30,7 @@ defineEmits<{
(e: 'link'): void;
(e: 'upload'): void;
(e: 'delete'): void;
(e: 'cancel'): void;
(e: 'changeStatus'): void;
}>();
@ -53,6 +58,7 @@ watch(
<q-menu class="bordered" ref="refMenu" :key="idName">
<q-list>
<q-item
v-if="!hideView"
v-close-popup
dense
clickable
@ -73,7 +79,7 @@ watch(
</q-item>
<q-item
v-if="status !== 'INACTIVE'"
v-if="status !== 'INACTIVE' && !hideEdit"
v-close-popup
dense
clickable
@ -136,7 +142,7 @@ watch(
</q-item>
<q-item
v-if="status !== 'INACTIVE'"
v-if="status !== 'INACTIVE' && !hideDelete"
v-close-popup
dense
class="row"
@ -158,7 +164,38 @@ watch(
}"
/>
<span class="col-9 q-px-md flex items-center">
{{ $t('general.delete') }}
<slot name="labelDelete">
{{ $t('general.delete') }}
</slot>
</span>
</q-item>
<q-item
v-if="useCancel"
v-close-popup
dense
class="row"
style="white-space: nowrap"
:clickable="!disableCancel"
:id="`btn-kebab-delete-${idName}`"
:class="{
'surface-3': disableCancel,
'app-text-muted': disableCancel,
}"
@click.stop="() => $emit('cancel')"
>
<q-icon
size="xs"
name="mdi-cancel"
class="col-3"
:class="{
'app-text-negative': !disableCancel,
}"
/>
<span class="col-9 q-px-md flex items-center">
<slot name="labelDelete">
{{ $t('general.cancel') }}
</slot>
</span>
</q-item>

View file

@ -0,0 +1,264 @@
<script lang="ts" setup>
import { Icon } from '@iconify/vue';
import AppBox from 'components/app/AppBox.vue';
import AppCircle from 'components/app/AppCircle.vue';
import KebabAction from './KebabAction.vue';
defineProps<{
data: {
name: string;
male?: boolean;
female?: boolean;
img?: string;
fallbackImg?: string;
detail?: { icon: string; value: string }[];
index: number;
};
tag?: [{ color: string; value: string }];
disabled?: boolean;
noHover?: boolean;
noBg?: boolean;
history?: boolean;
prefixId?: string;
separateEnter?: boolean;
}>();
defineEmits<{
(e: 'cancel', index: number): void;
}>();
</script>
<template>
<AppBox
bordered
no-padding
class="column person-box"
:class="{
'person-box__disabled': disabled,
'person-box__no-hover': noHover,
'person-box__no-detail': !data.detail,
'person-box__no-bg': noBg,
}"
>
<div class="column">
<!-- kebab menu -->
<div class="flex">
<div class="col-2 flax">
<AppCircle
bordered
class="avatar relative-position"
style="border: 2px solid var(--border-color); overflow: visible"
>
<q-img
v-if="!$slots.img"
:src="data.img ?? '/no-profile.png'"
loading="lazy"
fit="cover"
style="width: 100%; height: 100%; border-radius: 50%"
>
<template #error>
<div
style="background: none"
class="no-padding full-width full-height flex items-center justify-center"
>
<q-img
:src="data.fallbackImg || '/no-profile.png'"
fit="cover"
/>
</div>
</template>
</q-img>
<slot name="img"></slot>
<q-badge
class="absolute-bottom-right"
style="
border-radius: 50%;
width: 16px;
height: 16px;
z-index: 2;
background: var(--border-color);
"
>
<q-badge
class="absolute-center"
style="
border-radius: 7px;
width: 14px;
height: 14px;
background: hsl(var(--positive-bg));
"
></q-badge>
<!-- :style="`background: hsl(var(${active ? '--positive-bg' : '--text-mute'}))`" -->
</q-badge>
</AppCircle>
</div>
<div class="col column q-pl-xs">
<div class="col row">
<span style="max-width: calc(100% - 35%)">
<span class="row items-center ellipsis">
<div class="ellipsis" style="max-width: calc(100% - 30%)">
{{ data.name }}
<q-tooltip>
{{ data.name }}
</q-tooltip>
</div>
<Icon
v-if="data.male || data.female"
class="q-pl-xs"
:class="{
'symbol-gender': data.male || data.female,
'symbol-gender__male': !disabled && data.male,
'symbol-gender__female': !disabled && data.female,
'symbol-gender__disable': disabled,
}"
:icon="`material-symbols:${data.male ? 'male' : 'female'}`"
width="24px"
/>
</span>
</span>
<div class="row items-center q-gutter-x-xs q-ml-auto">
<q-btn
:id="`btn-history-${prefixId}`"
flat
round
class="app-text-muted-2"
padding="xs"
icon="mdi-close-circle"
size="sm"
@click.stop="$emit('cancel', data.index)"
/>
</div>
</div>
</div>
</div>
<!-- profile -->
<!-- name symbol -->
</div>
<!-- detail -->
</AppBox>
</template>
<style scoped>
.person-box {
background-color: var(--surface-1);
transition: 100ms ease-in-out;
padding: var(--size-2);
&.person-box__disabled {
opacity: 0.5;
filter: grayscale(0.9);
.status-circle {
background-color: var(--surface-1);
}
}
&.person-box__no-detail {
padding-block: 2rem;
}
&.person-box__no-bg {
background-color: transparent;
}
& .detail-icon {
color: hsl(var(--text-mute-2));
background-color: hsla(var(--stone-6-hsl) / 0.15);
border-radius: 50%;
scale: 0.8;
}
& .bg-gender {
color: hsla(var(--_fg));
background-color: hsl(var(--_bg));
&.bg-gender__disable {
--_bg: var(--stone-6-hsl);
background-color: hsla(var(--_bg) / 0.3);
}
&.bg-gender__light {
color: unset;
background-color: hsla(var(--_bg) / 0.1);
}
}
& .symbol-gender {
color: hsla(var(--_fg));
&.symbol-gender__disable {
--_fg: var(--stone-6-hsl);
}
&.symbol-gender__male {
--_fg: var(--gender-male);
}
&.symbol-gender__female {
--_fg: var(--gender-female);
}
}
& .status-circle {
width: 18px;
height: 18px;
border-radius: 50%;
background-color: var(--teal-6);
border: 2px solid var(--border-color);
bottom: 0.6rem;
right: 0.6rem;
position: absolute;
display: inline-flex;
justify-content: center;
align-items: center;
}
&:not(.person-box__disabled):not(.person-box__no-hover):hover {
--_hover: hsl(var(--gender-male));
cursor: pointer;
box-shadow: var(--shadow-2);
}
}
.person-container {
display: grid;
gap: var(--size-6);
}
.avatar {
block-size: 2.5rem;
/* transform: rotate(45deg); */
& .avatar__status {
content: ' ';
display: block;
block-size: 1rem;
aspect-ratio: 1;
position: absolute;
border-radius: 50%;
right: -0.5rem;
border: 1px solid var(--border-color);
top: calc(50% - 0.5rem);
bottom: calc(50% - 0.5rem);
background-color: hsla(var(--positive-bg) / 1);
}
/* & :deep(.q-img) {
transform: rotate(-45deg);
} */
}
.edit-profile {
cursor: pointer;
transition: opacity 0.2s ease;
&:hover {
opacity: 80%;
}
}
</style>

View file

@ -21,7 +21,7 @@ defineProps<{
noAction?: boolean;
noBg?: boolean;
history?: boolean;
prefixId: string;
prefixId?: string;
separateEnter?: boolean;
}>();
@ -99,6 +99,7 @@ defineEmits<{
<q-img
v-if="!$slots.img"
:src="data.img ?? '/no-profile.png'"
loading="lazy"
fit="cover"
style="width: 100%; height: 100%; border-radius: 50%"
>
@ -140,8 +141,11 @@ defineEmits<{
<span
class="items-center justify-center row text-center ellipsis col-6"
>
<div class="items-center ellipsis" style="max-width: 140px">
<div class="ellipsis" style="max-width: calc(100% - 30%)">
{{ data.name }}
<q-tooltip>
{{ data.name }}
</q-tooltip>
</div>
<Icon
v-if="data.male || data.female"

View file

@ -1,9 +1,9 @@
<script lang="ts" setup>
<script lang="ts" setup generic="T extends Record<string, unknown>">
import { onMounted, ref, watch } from 'vue';
import { selectFilterOptionRefMod } from 'src/stores/utils';
import { QSelect } from 'quasar';
const model = defineModel<string | null>();
const model = defineModel<string | string[] | Record<string, unknown> | null>();
const options = ref<Record<string, unknown>[]>([]);
let defaultFilter: (
@ -15,9 +15,9 @@ const props = withDefaults(
defineProps<{
id?: string;
label?: string;
option: Record<string, unknown>[];
optionLabel?: string;
optionValue?: string;
option: T[];
optionLabel?: keyof T;
optionValue?: keyof T;
placeholder?: string;
hideSelected?: boolean;
@ -26,6 +26,7 @@ const props = withDefaults(
incremental?: boolean;
fillInput?: boolean;
disable?: boolean;
multiple?: boolean;
rules?: ((value: string) => string | true)[];
}>(),
@ -40,14 +41,18 @@ const props = withDefaults(
);
defineEmits<{
(e: 'filter', val: string, update: void): void;
(
e: 'filter',
val: string,
update: (callbackFn: () => void, afterFn?: (ref: QSelect) => void) => void,
): void;
}>();
onMounted(() => {
defaultFilter = selectFilterOptionRefMod(
ref(props.option),
options,
props.optionLabel,
typeof props.optionLabel === 'string' ? props.optionLabel : 'label',
);
});
@ -57,7 +62,7 @@ watch(
defaultFilter = selectFilterOptionRefMod(
ref(props.option),
options,
props.optionLabel,
typeof props.optionLabel === 'string' ? props.optionLabel : 'label',
);
},
);
@ -71,15 +76,22 @@ watch(
use-input
emit-value
map-options
:hideSelected
:multiple
:use-chips="multiple"
:hide-selected
hide-bottom-space
:fill-input="fillInput && !!model"
:hide-dropdown-icon="readonly"
input-debounce="0"
:option-value="optionValue"
:option-label="optionLabel"
input-debounce="500"
:option-value="
typeof props.optionValue === 'string' ? props.optionValue : 'value'
"
:option-label="
typeof props.optionLabel === 'string' ? props.optionLabel : 'label'
"
v-model="model"
dense
autocomplete="off"
:readonly
:label="label"
:options="incremental ? option : options"
@ -91,8 +103,15 @@ watch(
"
:rules
>
<template v-if="$slots.prepend" v-slot:prepend>
<slot name="prepend"></slot>
</template>
<template v-if="$slots.append" v-slot:append>
<slot name="append"></slot>
</template>
<template v-slot:no-option>
<slot name="noOption"></slot>
<slot name="no-option"></slot>
<q-item v-if="!$slots.noOption">
<q-item-section class="text-grey">
@ -101,12 +120,19 @@ watch(
</q-item>
</template>
<template v-if="$slots.selectedItem" v-slot:selected-item="scope">
<slot name="selectedItem" :scope="scope"></slot>
<template
v-if="$slots.selectedItem || $slots['selected-item']"
v-slot:selected-item="scope"
>
<slot name="selected-item" :scope="scope" :opt="scope.opt as T"></slot>
</template>
<template v-if="$slots.option" v-slot:option="scope">
<slot name="option" :scope="scope"></slot>
<slot name="option" :scope="scope" :opt="scope.opt as T"></slot>
</template>
<template v-if="$slots['before-options']" #before-options>
<slot name="before-options"></slot>
</template>
</q-select>
</template>

View file

@ -0,0 +1,112 @@
<script lang="ts" setup>
import { ref } from 'vue';
const props = withDefaults(
defineProps<{
readonly?: boolean;
title?: string;
width?: string;
offset?: number[];
option: Record<string, unknown>[];
optionLabel?: string;
separatorIndex?: number[];
}>(),
{
readonly: false,
option: () => [],
optionLabel: 'label',
offset: () => [0, 12],
separatorIndex: () => [],
},
);
const inputSearch = ref('');
defineEmits<{
(e: 'search', value: string | number | null): void;
(e: 'select', value: Record<string, unknown>): void;
(e: 'hide'): void;
(e: 'show'): void;
(e: 'beforeHide'): void;
(e: 'beforeShow'): void;
}>();
</script>
<template>
<q-menu
v-if="!readonly"
:offset
class="bordered"
:style="`width: ${width}`"
@show="$emit('show')"
@hide="$emit('hide')"
@before-show="
() => {
inputSearch = '';
$emit('beforeShow');
}
"
@before-hide="$emit('beforeHide')"
>
<div
v-if="title"
class="column no-padding"
style="padding: 16px !important"
>
<span class="text-weight-bold q-pb-sm">
{{ title }}
</span>
<q-input
for="input-search"
outlined
dense
:label="$t('general.search')"
class="col responsible-search"
:bg-color="$q.dark.isActive ? 'dark' : 'white'"
v-model="inputSearch"
debounce="200"
@update:model-value="(val) => $emit('search', val)"
>
<template #prepend>
<q-icon name="mdi-magnify" />
</template>
</q-input>
</div>
<template v-if="$slots.prepend">
<slot name="prepend"></slot>
</template>
<span v-if="option.length === 0">
<q-item dense class="flex items-center app-text-muted">
{{ $t('general.noData') }}
</q-item>
</span>
<template v-if="option.length > 0">
<q-item
v-for="(opt, i) in option"
dense
:key="i"
class="flex items-center"
:class="{ 'bordered-t': separatorIndex.includes(i) }"
clickable
@click.stop="$emit('select', opt)"
>
<template v-if="$slots.option">
<slot name="option" :opt="opt"></slot>
</template>
<span v-if="!$slots.option" class="row items-center">
{{ opt.label }}
</span>
</q-item>
</template>
<template v-if="$slots.append">
<slot name="append"></slot>
</template>
</q-menu>
</template>
<style scoped></style>

View file

@ -0,0 +1,9 @@
<script setup lang="ts">
defineProps<{
value: any;
}>();
</script>
<template>
<slot :name="value" />
</template>

View file

@ -0,0 +1,104 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { createSelect, SelectProps } from './select-multiple';
import SelectInput from '../SelectInput.vue';
import useAddressStore, { Office } from 'stores/address';
type SelectOption = Office;
const value = defineModel<string[] | null | undefined>('value', {
required: true,
});
const valueOption = defineModel<SelectOption[]>('valueOption', {
required: false,
});
const selectOptions = ref<SelectOption[]>([]);
const { fetchOffice: getList, fetchOfficeById: getById } = useAddressStore();
defineEmits<{
(e: 'create'): void;
}>();
type ExclusiveProps = {};
const props = defineProps<SelectProps<typeof getList> & ExclusiveProps>();
const { getOptions, setFirstValue, getSelectedOption, filter } =
createSelect<SelectOption>(
{
value,
valueOption,
selectOptions,
getList: async (query) => {
const ret = await getList({
query,
...props.params,
});
if (ret) return ret;
},
getByValue: async (id) => {
const ret = await getById(id);
if (ret) return ret;
},
},
{ valueField: 'id' },
);
onMounted(async () => {
await getOptions();
if (props.autoSelectOnSingle && selectOptions.value.length === 1) {
setFirstValue();
}
await getSelectedOption();
});
</script>
<template>
<SelectInput
v-model="value"
option-value="id"
option-label="name"
incremental
multiple
:label
:placeholder
:readonly
:disable="disabled"
:option="selectOptions"
:hide-selected="false"
:fill-input="false"
:rules="[(v: string) => !!v || $t('form.error.required')]"
@filter="filter"
>
<template #before-options v-if="creatable">
<q-item clickable v-close-popup @click.stop="$emit('create')">
<q-item-section>
<b class="row items-center">
<q-icon
name="mdi-plus-circle-outline"
class="q-mr-sm"
style="color: hsl(var(--positive-bg))"
/>
{{ $t('general.add', { text: $t('quotation.newCustomer') }) }}
</b>
</q-item-section>
</q-item>
<q-separator class="q-mx-sm" />
</template>
<template #append v-if="clearable">
<q-icon
v-if="!readonly && value"
name="mdi-close-circle"
@click.stop="value = []"
class="cursor-pointer clear-btn"
/>
</template>
</SelectInput>
</template>

View file

@ -0,0 +1,110 @@
import { QSelect } from 'quasar';
import { ref, Ref, watch } from 'vue';
export type SelectProps<T extends (...args: any[]) => any> = {
params?: Parameters<T>[0];
creatable?: boolean;
label?: string;
placeholder?: string;
readonly?: boolean;
required?: boolean;
disabled?: boolean;
clearable?: boolean;
autoSelectOnSingle?: boolean;
};
export const createSelect = <T extends Record<string, any>>(
state: {
value: Ref<string[] | null | undefined>;
valueOption: Ref<T[] | undefined>;
selectOptions: Ref<T[]>;
getByValue: (id: string) => Promise<T | void> | T | void;
getList: (query?: string) => Promise<T[] | void> | T[] | void;
},
opts?: {
valueField?: keyof T;
},
) => {
const { value, valueOption, selectOptions, getList, getByValue } = state;
const valueField = opts?.valueField || 'value';
let cache: T[];
let previousSearch = '';
watch(value, (v) => {
console.log('UPDATED');
if (
!v ||
(cache && cache.find((opt) => v.find((val) => val === opt[valueField])))
) {
return;
}
getSelectedOption();
});
async function getOptions(query?: string) {
if (cache && selectOptions.value.length > 0 && previousSearch === query) {
selectOptions.value = JSON.parse(JSON.stringify(cache));
return;
}
const ret = await getList(query);
if (ret) {
cache = ret;
selectOptions.value = JSON.parse(JSON.stringify(cache));
previousSearch = query || previousSearch;
}
}
async function setFirstValue() {
if (value.value) return;
const first = selectOptions.value.at(0);
if (first) value.value = first[valueField];
}
async function getSelectedOption() {
const currentValue = value.value;
if (!currentValue) return;
if (selectOptions.value.find((v) => v[valueField] === currentValue)) return;
const newValueOptions = currentValue.map(async (a) => {
const findValue = valueOption.value?.find((b) => b[valueField] === a);
if (findValue) {
selectOptions.value.unshift(findValue);
return findValue;
}
const ret = await getByValue(a);
if (ret) {
selectOptions.value.unshift(ret);
return ret;
}
});
const retValueOptions = await Promise.all(newValueOptions);
valueOption.value = retValueOptions.flatMap((v) => (!!v ? v : []));
}
type QuasarSelectUpdate = (
callback: () => void,
afterFn?: ((ref: QSelect) => void) | undefined,
) => void;
function filter(value: string, update: QuasarSelectUpdate) {
update(
() => getOptions(value),
(ref) => {
if (!!value && ref.options && ref.options.length > 0) {
ref.setOptionIndex(-1);
ref.moveOptionSelection(1, true);
}
},
);
}
return { getOptions, setFirstValue, getSelectedOption, filter };
};

View file

@ -0,0 +1,117 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { getRole } from 'src/services/keycloak';
import { createSelect, SelectProps } from './select';
import SelectInput from '../SelectInput.vue';
import { Branch } from 'src/stores/branch/types';
import useStore from 'src/stores/branch';
type SelectOption = Branch;
const value = defineModel<string | null | undefined>('value', {
required: true,
});
const valueOption = defineModel<SelectOption>('valueOption', {
required: false,
});
const selectOptions = ref<SelectOption[]>([]);
const { fetchList: getList, fetchById: getById } = useStore();
defineEmits<{
(e: 'create'): void;
}>();
type ExclusiveProps = {
codeOnly?: boolean;
selectFirstValue?: boolean;
branchVirtual?: boolean;
checkRole?: string[];
};
const props = defineProps<SelectProps<typeof getList> & ExclusiveProps>();
const { getOptions, setFirstValue, getSelectedOption, filter } =
createSelect<SelectOption>(
{
value,
valueOption,
selectOptions,
getList: async (query) => {
const ret = await getList({
query,
...props.params,
activeOnly: true,
});
if (ret) return ret.result;
},
getByValue: async (id) => {
const ret = await getById(id);
if (ret) return ret;
},
},
{ valueField: 'id' },
);
onMounted(async () => {
await getOptions();
if (props.autoSelectOnSingle && selectOptions.value.length === 1) {
setFirstValue();
}
if (props.selectFirstValue) {
setDefaultValue();
} else await getSelectedOption();
});
function setDefaultValue() {
setFirstValue();
}
</script>
<template>
<SelectInput
v-model="value"
incremental
:label
:placeholder
:readonly
:disable="disabled"
:option="
selectOptions.map((v) => {
const ret = {
label: codeOnly
? v.code
: `${branchVirtual ? $t('branch.card.branchVirtual') : ''}` +
(
{
['eng']: [v.nameEN, `(${v.code})`].join(' '),
['tha']: [v.name, `(${v.code})`].join(' '),
} as const
)[$i18n.locale],
value: v.id,
};
return ret;
})
"
hide-selected
fill-input
:rules="[
(v: string) => !props.required || !!v || $t('form.error.required'),
]"
@filter="filter"
>
<template #append v-if="clearable">
<q-icon
v-if="!readonly && value"
name="mdi-close-circle"
@click.stop="value = ''"
class="cursor-pointer clear-btn"
/>
</template>
</SelectInput>
</template>

View file

@ -0,0 +1,174 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { createSelect, SelectProps } from './select';
import SelectInput from '../SelectInput.vue';
import SelectCustomerItem from './SelectCustomerItem.vue';
import useStore, { Customer, CustomerBranch } from 'src/stores/customer';
type SelectOption = CustomerBranch & { customer: Customer };
const value = defineModel<string | null | undefined>('value', {
required: true,
});
const valueOption = defineModel<SelectOption>('valueOption', {
required: false,
});
const selectOptions = ref<SelectOption[]>([]);
const {
fetchListCustomerBranch: getList,
fetchListCustomerBranchById: getById,
} = useStore();
defineEmits<{
(e: 'create'): void;
}>();
type ExclusiveProps = {
simple?: boolean;
simpleBranchNo?: boolean;
};
const props = defineProps<SelectProps<typeof getList> & ExclusiveProps>();
const { getOptions, setFirstValue, getSelectedOption, filter } =
createSelect<SelectOption>(
{
value,
valueOption,
selectOptions,
getList: async (query) => {
const ret = await getList({
query,
...props.params,
activeRegisBranchOnly: true,
includeCustomer: true,
});
if (ret) return ret.result;
},
getByValue: async (id) => {
const ret = await getById(id);
if (ret) return ret;
},
},
{ valueField: 'id' },
);
onMounted(async () => {
await getOptions();
if (props.autoSelectOnSingle && selectOptions.value.length === 1) {
setFirstValue();
}
await getSelectedOption();
});
</script>
<template>
<SelectInput
v-model="value"
option-value="id"
incremental
:label
:placeholder
:readonly
:disable="disabled"
:option="selectOptions"
:hide-selected="false"
:fill-input="false"
:rules="[(v: string) => !!v || $t('form.error.required')]"
@filter="filter"
>
<template #selected-item="{ opt }">
<SelectCustomerItem
v-if="typeof opt === 'object'"
:data="opt"
:simple
:simple-branch-no
single-line
/>
</template>
<template #no-option v-if="creatable">
<q-item
:disable="creatableDisabled"
clickable
v-close-popup
@click.stop="$emit('create')"
>
<q-item-section>
<span class="row items-center">
<q-icon
name="mdi-plus-circle-outline"
class="q-mr-sm"
style="color: hsl(var(--positive-bg))"
/>
<b>
{{ $t('general.add', { text: $t('quotation.newCustomer') }) }}
</b>
<span
v-if="creatableDisabled && creatableDisabledText"
class="app-text-muted q-pl-xs"
style="font-size: 80%"
>
{{ creatableDisabledText }}
</span>
</span>
</q-item-section>
</q-item>
<q-separator class="q-mx-sm" />
</template>
<template #before-options v-if="creatable">
<q-item
:disable="creatableDisabled"
clickable
v-close-popup
@click.stop="$emit('create')"
>
<q-item-section>
<span class="row items-center">
<q-icon
name="mdi-plus-circle-outline"
class="q-mr-sm"
style="color: hsl(var(--positive-bg))"
/>
<b>
{{ $t('general.add', { text: $t('quotation.newCustomer') }) }}
</b>
<span
v-if="creatableDisabled && creatableDisabledText"
class="app-text-muted q-pl-xs"
style="font-size: 80%"
>
{{ creatableDisabledText }}
</span>
</span>
</q-item-section>
</q-item>
<q-separator class="q-mx-sm" />
</template>
<template #option="{ opt, scope }">
<q-item v-bind="scope.itemProps">
<SelectCustomerItem :data="opt" :simple :simple-branch-no />
</q-item>
<q-separator class="q-mx-sm" />
</template>
<template #append v-if="clearable">
<q-icon
v-if="!readonly && value"
name="mdi-close-circle"
@click.stop="value = ''"
class="cursor-pointer clear-btn"
/>
</template>
</SelectInput>
</template>

View file

@ -0,0 +1,106 @@
<script lang="ts" setup>
import { Customer, CustomerBranch, CustomerType } from 'src/stores/customer';
import useOptionStore from 'src/stores/options';
import { formatAddress } from 'src/utils/address';
import { Lang } from 'src/utils/ui';
defineProps<{
data?: CustomerBranch & { customer: Customer };
simple?: boolean;
simpleBranchNo?: boolean;
singleLine?: boolean;
}>();
const optionStore = useOptionStore();
</script>
<template>
<template v-if="data">
<div v-if="simple" class="row items-center">
{{
{
[CustomerType.Corporate]:
{
[1]: data.registerNameEN?.trim(),
[0]: data.registerName?.trim(),
}[+($i18n.locale === Lang.English)] || '-',
[CustomerType.Person]:
{
[1]: `${optionStore.mapOption(data.namePrefix)} ${data.firstNameEN} ${data.lastNameEN}`.trim(),
[0]: `${optionStore.mapOption(data.namePrefix)} ${data.firstName} ${data.lastName}`.trim(),
}[+($i18n.locale === Lang.English)] || '-',
}[data.customer.customerType]
}}
({{
simpleBranchNo
? (
$t(
`branch.form.title.${data.code.endsWith('-00') ? 'branchHQLabel' : 'branchLabel'}`,
) +
' ' +
(!data.code.endsWith('-00')
? String(+data.code.split('-')[1])
: '')
).trim()
: data.code
}})
</div>
<div
v-else
:class="{
['row']: singleLine,
['items-center']: singleLine,
}"
>
<div class="q-mr-sm">
<span style="font-weight: 600">
{{
data.customer.customerType === 'CORP'
? $t('customer.form.registerName')
: $t('customer.form.ownerName')
}}:
</span>
{{
{
[CustomerType.Corporate]: {
[1]: data.registerNameEN,
[0]: data.registerName,
}[+($i18n.locale === Lang.English)],
[CustomerType.Person]:
{
[1]: `${optionStore.mapOption(data.namePrefix)} ${data.firstNameEN} ${data.lastNameEN}`,
[0]: `${optionStore.mapOption(data.namePrefix)} ${data.firstName} ${data.lastName}`,
}[+($i18n.locale === Lang.English)] || '-',
}[data.customer.customerType]
}}
({{ data.code }})
</div>
<div
class="text-caption app-text-muted-2"
v-if="data.customer && data.province"
>
{{
$t(
`branch.form.title.${data.code.endsWith('-00') ? 'branchHQLabel' : 'branchLabel'}`,
)
}}
{{ !data.code.endsWith('-00') ? +data.code.split('-')[1] : '' }}
</div>
<div
class="text-caption app-text-muted-2"
v-if="data.customer && data.province"
>
{{ $t('general.address') }}
{{ formatAddress({ ...data, en: $i18n.locale === Lang.English }) }}
<q-tooltip v-if="data.customer && data.province">
{{ $t('customerBranch.form.title') }}:
{{ $t('general.address') }}
{{ formatAddress({ ...data, en: $i18n.locale === Lang.English }) }}
</q-tooltip>
</div>
</div>
</template>
</template>

View file

@ -0,0 +1,108 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { createSelect, SelectProps } from './select';
import SelectInput from '../SelectInput.vue';
import { WorkflowTemplate } from 'src/stores/workflow-template/types';
import { useWorkflowTemplate } from 'src/stores/workflow-template';
type SelectOption = WorkflowTemplate;
const value = defineModel<string | null | undefined>('value', {
required: true,
});
const valueOption = defineModel<SelectOption>('valueOption', {
required: false,
});
const selectOptions = ref<SelectOption[]>([]);
const { getWorkflowTemplateList: getList, getWorkflowTemplate: getById } =
useWorkflowTemplate();
defineEmits<{
(e: 'create'): void;
(e: 'updateValue', val: string): void;
}>();
type ExclusiveProps = {
selectFirstValue?: boolean;
};
const props = defineProps<SelectProps<typeof getList> & ExclusiveProps>();
const { getOptions, setFirstValue, getSelectedOption, filter } =
createSelect<SelectOption>(
{
value,
valueOption,
selectOptions,
getList: async (query) => {
const ret = await getList({
query,
...props.params,
activeOnly: true,
});
if (ret) return ret.result;
},
getByValue: async (id) => {
const ret = await getById(id);
if (ret) return ret;
},
},
{ valueField: 'id' },
);
onMounted(async () => {
await getOptions();
if (props.autoSelectOnSingle && selectOptions.value.length === 1) {
setFirstValue();
}
if (props.selectFirstValue) {
setDefaultValue();
} else await getSelectedOption();
});
function setDefaultValue() {
setFirstValue();
}
</script>
<template>
<SelectInput
v-model="value"
incremental
:label
:placeholder
:readonly
:disable="disabled"
:option="
selectOptions.map((v) => {
const ret = {
label: v.name,
value: v.id,
};
return ret;
})
"
:hide-selected="false"
:fill-input="false"
:rules="
required ? [(v: string) => !!v || $t('form.error.required')] : undefined
"
@filter="filter"
@update:model-value="(v) => $emit('updateValue', v as string)"
>
<template #append v-if="clearable">
<q-icon
v-if="!readonly && value"
name="mdi-close-circle"
@click.stop="value = ''"
class="cursor-pointer clear-btn"
/>
</template>
</SelectInput>
</template>

View file

@ -0,0 +1,107 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { createSelect, SelectProps } from './select';
import SelectInput from '../SelectInput.vue';
import { Institution } from 'src/stores/institution/types';
import { useInstitution } from 'src/stores/institution';
type SelectOption = Institution;
const value = defineModel<string | null | undefined>('value', {
required: true,
});
const valueOption = defineModel<SelectOption>('valueOption', {
required: false,
});
const selectOptions = ref<SelectOption[]>([]);
const { getInstitutionList: getList, getInstitution: getById } =
useInstitution();
defineEmits<{
(e: 'create'): void;
(e: 'updateValue', val: string): void;
}>();
type ExclusiveProps = {
selectFirstValue?: boolean;
};
const props = defineProps<SelectProps<typeof getList> & ExclusiveProps>();
const { getOptions, setFirstValue, getSelectedOption, filter } =
createSelect<SelectOption>(
{
value,
valueOption,
selectOptions,
getList: async (query) => {
const ret = await getList({
query: query === '' ? undefined : query,
...props.params,
});
if (ret) return ret.result;
},
getByValue: async (id) => {
const ret = await getById(id);
if (ret) return ret;
},
},
{ valueField: 'id' },
);
onMounted(async () => {
await getOptions();
if (props.autoSelectOnSingle && selectOptions.value.length === 1) {
setFirstValue();
}
if (props.selectFirstValue) {
setDefaultValue();
} else await getSelectedOption();
});
function setDefaultValue() {
setFirstValue();
}
</script>
<template>
<SelectInput
v-model="value"
incremental
:label
:placeholder
:readonly
:disable="disabled"
:option="
selectOptions.map((v) => {
const ret = {
label: v.name,
value: v.id,
};
return ret;
})
"
:hide-selected="false"
:fill-input="false"
:rules="
required ? [(v: string) => !!v || $t('form.error.required')] : undefined
"
@filter="filter"
@update:model-value="(v) => $emit('updateValue', v as string)"
>
<template #append v-if="clearable">
<q-icon
v-if="!readonly && value"
name="mdi-close-circle"
@click.stop="value = ''"
class="cursor-pointer clear-btn"
/>
</template>
</SelectInput>
</template>

View file

@ -0,0 +1,124 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { createSelect, SelectProps } from './select';
import SelectInput from '../SelectInput.vue';
import { ProductGroup } from 'stores/product-service/types';
import useStore from 'stores/product-service';
type SelectOption = ProductGroup;
const value = defineModel<string | null | undefined>('value', {
required: true,
});
const valueOption = defineModel<SelectOption>('valueOption', {
required: false,
});
const selectOptions = ref<SelectOption[]>([]);
const { fetchProductGroup: getList, fetchProductGroupById: getById } =
useStore();
defineEmits<{
(e: 'create'): void;
}>();
type ExclusiveProps = {
selectFirstValue?: boolean;
};
const props = defineProps<SelectProps<typeof getList> & ExclusiveProps>();
const { getOptions, setFirstValue, getSelectedOption, filter } =
createSelect<SelectOption>(
{
value,
valueOption,
selectOptions,
getList: async (query) => {
const ret = await getList({
query,
...props.params,
activeOnly: true,
});
if (ret) return ret.result;
},
getByValue: async (id) => {
const ret = await getById(id);
if (ret) return ret;
},
},
{ valueField: 'id' },
);
onMounted(async () => {
await getOptions();
if (props.autoSelectOnSingle && selectOptions.value.length === 1) {
setFirstValue();
}
if (props.selectFirstValue) {
setDefaultValue();
} else await getSelectedOption();
});
function setDefaultValue() {
setFirstValue();
}
</script>
<template>
<SelectInput
v-model="value"
incremental
option-label="code"
option-value="id"
:label
:placeholder
:readonly
:disable="disabled"
:option="selectOptions"
:hide-selected="false"
:fill-input="false"
:rules="[
(v: string) => !props.required || !!v || $t('form.error.required'),
]"
@filter="filter"
>
<template #append v-if="clearable">
<q-icon
v-if="!readonly && value"
name="mdi-close-circle"
@click.stop="value = ''"
class="cursor-pointer clear-btn"
/>
</template>
<template #option="{ scope }">
<q-item
v-if="scope.opt"
v-bind="scope.itemProps"
class="row items-center"
>
<q-item-section>
{{ scope.opt.name }}
<span class="app-text-muted text-caption">
{{ scope.opt.code }}
</span>
</q-item-section>
</q-item>
</template>
<template #selected-item="{ opt }">
<q-item-section v-if="opt">
{{ opt.name }}
<span class="app-text-muted text-caption">
{{ opt.code }}
</span>
</q-item-section>
</template>
</SelectInput>
</template>

View file

@ -0,0 +1,108 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { createSelect, SelectProps } from './select';
import SelectInput from '../SelectInput.vue';
import useStore, { User } from 'src/stores/user';
import { Lang } from 'src/utils/ui';
type SelectOption = User;
const value = defineModel<string | null | undefined>('value', {
required: true,
});
const valueOption = defineModel<SelectOption>('valueOption', {
required: false,
});
const selectOptions = ref<SelectOption[]>([]);
const { fetchList: getList, fetchById: getById } = useStore();
defineEmits<{
(e: 'create'): void;
}>();
type ExclusiveProps = {
selectFirstValue?: boolean;
};
const props = defineProps<SelectProps<typeof getList> & ExclusiveProps>();
const { getOptions, setFirstValue, getSelectedOption, filter } =
createSelect<SelectOption>(
{
value,
valueOption,
selectOptions,
getList: async (query) => {
const ret = await getList({
query,
...props.params,
});
if (ret) return ret.result;
},
getByValue: async (id) => {
const ret = await getById(id);
if (ret) return ret;
},
},
{ valueField: 'id' },
);
onMounted(async () => {
await getOptions();
if (props.autoSelectOnSingle && selectOptions.value.length === 1) {
setFirstValue();
}
if (props.selectFirstValue) {
setDefaultValue();
} else await getSelectedOption();
});
function setDefaultValue() {
setFirstValue();
}
</script>
<template>
<SelectInput
v-model="value"
incremental
:label
:placeholder
:readonly
:disable="disabled"
:option="
selectOptions.map((v) => {
const ret = {
label: (
{
[Lang.English]: [v.firstNameEN, v.lastNameEN].join(' '),
[Lang.Thai]: [v.firstName, v.lastName].join(' '),
} as const
)[$i18n.locale],
value: v.id,
};
return ret;
})
"
:hide-selected="false"
:fill-input="false"
:rules="
required ? [(v: string) => !!v || $t('form.error.required')] : undefined
"
@filter="filter"
>
<template #append v-if="clearable">
<q-icon
v-if="!readonly && value"
name="mdi-close-circle"
@click.stop="value = ''"
class="cursor-pointer clear-btn"
/>
</template>
</SelectInput>
</template>

View file

@ -0,0 +1,97 @@
import { QSelect } from 'quasar';
import { Ref, watch } from 'vue';
export type SelectProps<T extends (...args: any[]) => any> = {
params?: Parameters<T>[0];
creatable?: boolean;
creatableDisabled?: boolean;
creatableDisabledText?: string;
label?: string;
placeholder?: string;
readonly?: boolean;
required?: boolean;
disabled?: boolean;
clearable?: boolean;
autoSelectOnSingle?: boolean;
};
export const createSelect = <T extends Record<string, any>>(
state: {
value: Ref<string | null | undefined>;
valueOption: Ref<T | undefined>;
selectOptions: Ref<T[]>;
getByValue: (id: string) => Promise<T | void> | T | void;
getList: (query?: string) => Promise<T[] | void> | T[] | void;
},
opts?: {
valueField?: keyof T;
},
) => {
const { value, valueOption, selectOptions, getList, getByValue } = state;
const valueField = opts?.valueField || 'value';
let cache: T[];
let previousSearch = '';
watch(value, (v) => {
if (!v || (cache && cache.find((opt) => opt[valueField] === v))) return;
getSelectedOption();
});
async function getOptions(query?: string) {
if (cache && selectOptions.value.length > 0 && previousSearch === query) {
selectOptions.value = JSON.parse(JSON.stringify(cache));
return;
}
const ret = await getList(query);
if (ret) {
cache = ret;
selectOptions.value = JSON.parse(JSON.stringify(cache));
previousSearch = query || previousSearch;
}
getSelectedOption();
}
async function setFirstValue() {
if (value.value) return;
const first = selectOptions.value.at(0);
if (first) value.value = first[valueField];
}
async function getSelectedOption() {
const currentValue = value.value;
if (!currentValue) return;
if (selectOptions.value.find((v) => v[valueField] === currentValue)) return;
if (valueOption.value && valueOption.value[valueField] === currentValue) {
return selectOptions.value.unshift(valueOption.value);
}
const ret = await getByValue(currentValue);
if (ret) {
selectOptions.value.unshift(ret);
valueOption.value = ret;
}
}
type QuasarSelectUpdate = (
callback: () => void,
afterFn?: ((ref: QSelect) => void) | undefined,
) => void;
function filter(value: string, update: QuasarSelectUpdate) {
update(
() => getOptions(value),
(ref) => {
if (!!value && ref.options && ref.options.length > 0) {
ref.setOptionIndex(-1);
ref.moveOptionSelection(1, true);
}
},
);
}
return { getOptions, setFirstValue, getSelectedOption, filter };
};

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