Merge branch 'develop'
Some checks failed
Spell Check / Spell Check with Typos (push) Failing after 10s

This commit is contained in:
Methapon2001 2025-02-28 16:51:14 +07:00
commit 96fc372bf2
16 changed files with 490 additions and 128 deletions

View file

@ -1,8 +1,4 @@
name: Gitea Action
run-name: Build ${{ github.actor }}
# Intended for local gitea instance only.
name: Deploy Local
on:
workflow_dispatch:
@ -37,22 +33,14 @@ jobs:
platforms: linux/amd64
tags: ${{ env.CONTAINER_IMAGE_NAME }}
push: true
- name: Remote Deploy Development
- name: Remote Deploy
uses: appleboy/ssh-action@v1.2.1
with:
host: ${{ vars.SSH_DEVELOPMENT_HOST }}
port: ${{ vars.SSH_DEVELOPMENT_PORT }}
username: ${{ secrets.SSH_DEVELOPMENT_USER }}
password: ${{ secrets.SSH_DEVELOPMENT_PASSWORD }}
script: eval "${{ secrets.SSH_DEVELOPMENT_DEPLOY_CMD }}" & wait
- name: Remote Deploy Test
uses: appleboy/ssh-action@v1.2.1
with:
host: ${{ vars.SSH_TEST_HOST }}
port: ${{ vars.SSH_TEST_PORT }}
username: ${{ secrets.SSH_TEST_USER }}
password: ${{ secrets.SSH_TEST_PASSWORD }}
script: eval "${{ secrets.SSH_TEST_DEPLOY_CMD }}" & wait
host: ${{ vars.SSH_DEPLOY_HOST }}
port: ${{ vars.SSH_DEPLOY_PORT }}
username: ${{ secrets.SSH_DEPLOY_USER }}
password: ${{ secrets.SSH_DEPLOY_PASSWORD }}
script: eval "${{ secrets.SSH_DEPLOY_CMD }}"
- name: Notify Discord Success
if: success()
run: |

View file

@ -0,0 +1,21 @@
name: Spell Check
permissions:
contents: read
on: [push, pull_request]
env:
CLICOLOR: 1
jobs:
spelling:
name: Spell Check with Typos
runs-on: ubuntu-latest
steps:
- name: Checkout Actions Repository
uses: actions/checkout@v4
- name: Spell Check Repo
uses: crate-ci/typos@v1.29.9
with:
files: ./src

View file

@ -1,31 +0,0 @@
name: local-build-dev
# Intended for local network use.
# Remote access is possible if the host has a public IP address.
on:
workflow_dispatch:
env:
REGISTRY: ${{ vars.DOCKER_REGISTRY }}
jobs:
local-build-dev:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Docker Buildx
uses: docker/setup-buildx-action@v2
with:
config-inline: |
[registry."${{ env.REGISTRY }}"]
http = true
insecure = true
- name: Build and Push Docker Image
uses: docker/build-push-action@v3
with:
context: .
platforms: linux/amd64
push: true
tags: ${{ env.REGISTRY }}/jws/jws-frontend:dev
allow: security.insecure

2
.typos.toml Normal file
View file

@ -0,0 +1,2 @@
[default]
extend-ignore-re = ["(?Rm)^.*(#|//)\\s*spellchecker:disable-line$"]

View file

@ -45,6 +45,8 @@ type ExclusiveProps = {
type: 'service' | 'product';
grid?: boolean;
disabledWorkerId?: string[];
disabledProductFields?: (typeof columnsProduct)[number]['name'][];
disabledServiceFields?: (typeof columnsService)[number]['name'][];
rows: Product[];
};
@ -56,6 +58,13 @@ const columnsProduct = [
field: (_) => '#check',
},
{
name: 'order',
align: 'center',
label: 'general.order',
field: (e: Product & { _index: number }) => e._index + 1,
},
{
name: '#productName',
align: 'left',
@ -76,7 +85,7 @@ const columnsProduct = [
label: 'productService.product.priceInformation',
field: (v: Product) => v,
},
] satisfies QTableColumn[];
] as const satisfies QTableColumn[];
const columnsService = [
{
@ -85,6 +94,12 @@ const columnsService = [
label: '',
field: (_) => '#check',
},
{
name: 'order',
align: 'center',
label: 'general.order',
field: (e: Service & { _index: number }) => e._index + 1,
},
{
name: '#serviceName',
@ -110,7 +125,7 @@ const columnsService = [
label: 'general.createdAt',
field: (v: Service) => dateFormatJS({ date: v.createdAt }),
},
] satisfies QTableColumn[];
] as const satisfies QTableColumn[];
const props = defineProps<ExclusiveProps>();
@ -165,7 +180,18 @@ function selectedIndex(item: any) {
:props="props"
>
<q-th
v-for="col in type === 'product' ? columnsProduct : columnsService"
v-for="col in type === 'product'
? columnsProduct.filter((v) =>
disabledProductFields
? !disabledProductFields.includes(v.name)
: true,
)
: columnsService.filter((v) =>
disabledServiceFields
? !disabledServiceFields.includes(v.name)
: true,
)"
:auto-width="col.name === '#check'"
:key="col.name"
:props="props"
>
@ -209,7 +235,17 @@ function selectedIndex(item: any) {
class="text-center"
>
<q-td
v-for="col in type === 'product' ? columnsProduct : columnsService"
v-for="col in type === 'product'
? columnsProduct.filter((v) =>
disabledProductFields
? !disabledProductFields.includes(v.name)
: true,
)
: columnsService.filter((v) =>
disabledServiceFields
? !disabledServiceFields.includes(v.name)
: true,
)"
:align="col.align"
:key="col.name"
>

View file

@ -20,7 +20,7 @@ export default {
confirm: 'Confirm',
login: 'Login',
logout: 'Logout',
manage: 'Manage',
manage: 'Manage {text}',
theme: 'Theme',
light: 'Light',
dark: 'Dark',
@ -915,6 +915,10 @@ export default {
salesRepresentative: 'Sales Representative',
ref: 'Reference',
action: {
title: 'Action',
configure: 'Configure',
},
status: {
work: {
Pending: 'Await for order',

View file

@ -20,7 +20,7 @@ export default {
confirm: 'ยืนยัน',
login: 'เข้าสู่ระบบ',
logout: 'ออกจากระบบ',
manage: 'จัดการ',
manage: 'จัดการ{text}',
theme: 'ธีม',
light: 'สว่าง',
dark: 'มืด',
@ -904,6 +904,10 @@ export default {
noWorkflowTemplate: 'คุณไม่ได้เลือกแม่แบบขั้นตอนการทำงาน',
salesRepresentative: 'พนักงานขาย',
ref: 'อ้างอิง',
action: {
title: 'จัดการ',
configure: 'กำหนดค่า',
},
status: {
work: {
Pending: 'รอสั่งงาน',

View file

@ -12,7 +12,6 @@ const responsibleUserId = defineModel<string>('responsibleUserId', {
defineProps<{
readonly?: boolean;
districtId?: string;
cost?: boolean;
}>();
watch(responsibleUserLocal, (lhs, rhs) => {

View file

@ -0,0 +1,229 @@
<script setup lang="ts">
import { reactive, ref } from 'vue';
import { RequestWork } from 'src/stores/request-list';
import { DialogContainer, DialogHeader } from 'src/components/dialog';
import {
BackButton,
CancelButton,
MainButton,
SaveButton,
} from 'src/components/button';
import TableProductAndService from 'src/components/shared/table/TableProductAndService.vue';
import { Product } from 'src/stores/product-service/types';
import FormResponsibleUser from './FormResponsibleUser.vue';
import FormGroupHead from './FormGroupHead.vue';
defineProps<{
work: RequestWork[];
responsibleDistrictId: string;
}>();
defineEmits<{
(
e: 'submit',
data: {
form: { responsibleUserLocal: boolean; responsibleUserId: string };
selected: { _work: RequestWork }[];
},
): void;
}>();
enum Step {
Product = 1,
Configure = 2,
}
const open = defineModel<boolean>('selected', { default: false });
const step = ref<Step>(Step.Product);
const selected = ref<{ _work: RequestWork }[]>([]);
const form = reactive({
responsibleUserLocal: false,
responsibleUserId: '',
});
function reset() {
step.value = Step.Product;
selected.value = [];
}
function prev() {
step.value = Step.Product;
}
</script>
<template>
<DialogContainer v-model="open" :onOpen="reset">
<template #header>
<DialogHeader :title="$t('requestList.action.title')" />
</template>
<div class="surface-0 q-pa-md">
<div class="stepper-wrapper">
<div class="stepper">
<template
v-for="(label, i) in [
$t('menu.product'),
$t('requestList.action.configure'),
]"
:key="i"
>
<span class="step" :class="{ ['step__active']: step > i }">
<span class="step-outer"><span class="step-inner" /></span>
<span class="step-label">{{ label }}</span>
</span>
<span
class="step-connector"
:class="{ ['step-connector__active']: step > i + 1 }"
/>
</template>
</div>
</div>
</div>
<div class="surface-1 q-pa-md col">
<TableProductAndService
v-if="step === Step.Product"
type="product"
v-model:selected="selected"
:disabled-product-fields="[
'#priceInformation',
'productProcessingTime',
]"
:rows="
(work?.map((v) => ({
...v.productService.product,
type: 'product',
_work: v,
})) || []) as unknown as Product[]
"
/>
<template v-if="step === Step.Configure">
<q-expansion-item
dense
class="overflow-hidden bordered full-width"
switch-toggle-side
style="border-radius: var(--radius-2)"
expand-icon="mdi-chevron-down-circle"
header-class="surface-1 q-py-sm text-medium text-body1"
default-opened
>
<template #header>
<span>
{{ $t('requestList.employeeMessenger') }}
</span>
</template>
<FormGroupHead>
{{
$t('general.select', { msg: $t('requestList.employeeMessenger') })
}}
</FormGroupHead>
<FormResponsibleUser
:district-id="responsibleDistrictId"
v-model:responsible-user-id="form.responsibleUserId"
v-model:responsible-user-local="form.responsibleUserLocal"
/>
</q-expansion-item>
</template>
</div>
<template #footer>
<div class="q-gutter-x-xs q-ml-auto">
<CancelButton
v-if="step === Step.Product"
id="btn-cancel"
outlined
@click="
reset();
open = false;
"
/>
<BackButton
v-if="step === Step.Configure"
id="btn-back"
outlined
@click="prev"
/>
<MainButton
icon="mdi-check"
color="207 96% 32%"
solid
id="btn-next"
v-if="step === Step.Product"
@click="step = Step.Configure"
>
{{ $t('general.next') }}
</MainButton>
<SaveButton
v-if="step === Step.Configure"
id="btn-save"
solid
@click="$emit('submit', { form, selected })"
/>
</div>
</template>
</DialogContainer>
</template>
<style lang="scss" scoped>
.stepper {
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 1.5rem;
margin-inline: 25%;
& > .step {
--__color: var(--gray-5);
display: flex;
flex-direction: column;
align-items: center;
position: relative;
gap: 0.25rem;
&.step__active {
--__color: var(--brand-1);
}
& > .step-label {
position: absolute;
font-weight: 600;
color: var(--__color);
white-space: nowrap;
top: 2rem;
}
& > .step-outer {
display: inline-flex;
border: 2px solid var(--__color);
border-radius: 50%;
width: 1.5rem;
height: 1.5rem;
align-items: center;
justify-content: center;
& > .step-inner {
display: inline-block;
border-radius: 50%;
background-color: var(--__color);
width: 0.7rem;
height: 0.7rem;
}
}
}
& > .step-connector {
display: block;
border-bottom: 2px solid var(--gray-5);
flex-grow: 1;
&.step-connector__active {
border-color: var(--brand-1);
}
&:last-child {
display: none;
}
}
}
</style>

View file

@ -11,9 +11,10 @@ import PropertiesExpansion from './PropertiesExpansion.vue';
import FormGroupHead from './FormGroupHead.vue';
import AvatarGroup from 'src/components/shared/AvatarGroup.vue';
import { StateButton } from 'components/button';
import { CancelButton } from 'components/button';
import { CancelButton, MainButton } from 'components/button';
import DutyExpansion from './DutyExpansion.vue';
import MessengerExpansion from './MessengerExpansion.vue';
import RequestAction from './RequestAction.vue';
// NOTE: Store
import {
@ -68,9 +69,41 @@ const statusFile = ref<Attributes>({
const refDocumentExpansion = ref<InstanceType<typeof DocumentExpansion>[]>([]);
const data = ref<RequestData>();
const flow = ref<WorkflowTemplate>();
const productsList = computed(() =>
workList.value
?.filter((v) =>
v.productService.work?.attributes.workflowStep?.[
pageState.currentStep - 1
]?.productsId.includes(v.productService.productId),
)
.map((v) => {
const _props =
v.productService.work?.attributes?.workflowStep[
pageState.currentStep - 1
]?.attributes?.properties;
return Object.assign(v, {
_documentExpansion: _props.some(
(v: PropVariant) => v.fieldName === 'documentCheck',
),
_formExpansion: _props.some(
(v: PropVariant) => v.fieldName === 'designForm',
),
_dutyExpansion: _props.some((v: PropVariant) => v.fieldName === 'duty'),
_messengerExpansion: _props.some(
(v: PropVariant) => v.fieldName === 'messenger',
),
});
})
.sort(
(lhs, rhs) =>
lhs.productService.installmentNo - rhs.productService.installmentNo,
),
);
const pageState = reactive({
hideMetaData: false,
currentStep: 1,
requestActionDialog: false,
});
// NOTE: Function
@ -342,6 +375,31 @@ function closeTab() {
cancel: () => {},
});
}
function openRequestAction() {
pageState.requestActionDialog = true;
}
async function submitRequestAction(data: {
form: { responsibleUserLocal: boolean; responsibleUserId: string };
selected: { _work: RequestWork }[];
}) {
const requestWorksId = data.selected.map((s) => s._work.id);
const res = await requestListStore.actionRequestWork(
route.params['requestListId'] as string,
pageState.currentStep,
requestWorksId.map((v) => ({
responsibleUserId: data.form.responsibleUserId,
responsibleUserLocal: data.form.responsibleUserLocal,
requestWorkId: v!,
})),
);
if (res) {
await getData();
pageState.requestActionDialog = false;
}
}
</script>
<template>
<div class="column surface-0 fullscreen" v-if="data">
@ -671,40 +729,7 @@ function closeTab() {
</transition>
</article>
<!-- product -->
<template
v-for="(value, index) in workList
?.filter((v) =>
v.productService.work?.attributes.workflowStep?.[
pageState.currentStep - 1
]?.productsId.includes(v.productService.productId),
)
.map((v) => {
const _props =
v.productService.work?.attributes?.workflowStep[
pageState.currentStep - 1
]?.attributes?.properties;
return Object.assign(v, {
_documentExpansion: _props.some(
(v: PropVariant) => v.fieldName === 'documentCheck',
),
_formExpansion: _props.some(
(v: PropVariant) => v.fieldName === 'designForm',
),
_dutyExpansion: _props.some(
(v: PropVariant) => v.fieldName === 'duty',
),
_messengerExpansion: _props.some(
(v: PropVariant) => v.fieldName === 'messenger',
),
});
})
.sort(
(lhs, rhs) =>
lhs.productService.installmentNo -
rhs.productService.installmentNo,
)"
:key="value"
>
<template v-for="(value, index) in productsList" :key="value">
<ProductExpansion
:cancel="data.requestDataStatus === RequestDataStatus.Canceled"
:readonly="
@ -873,13 +898,34 @@ function closeTab() {
</main>
<!-- SEC: Footer -->
<footer class="surface-1 q-pa-md full-width text-right">
<footer
class="surface-1 q-pa-md full-width row justify-end"
style="gap: var(--size-2)"
>
<CancelButton
outlined
@click="closeTab()"
:label="$t('dialog.action.close')"
outlined
/>
<MainButton
v-if="productsList.some((v) => v._messengerExpansion)"
solid
icon="mdi-account-outline"
color="207 96% 32%"
@click="openRequestAction"
>
{{
$t('general.manage', { text: $t('requestList.employeeMessenger') })
}}
</MainButton>
</footer>
</div>
<RequestAction
v-model="pageState.requestActionDialog"
:work="workList"
:responsible-district-id="data?.quotation.customerBranch.districtId || ''"
@submit="submitRequestAction"
/>
</template>
<style scoped></style>

View file

@ -113,7 +113,8 @@ function handleCheckAll() {
validate
? status === TaskStatus.Success ||
status === TaskStatus.Complete ||
status === TaskStatus.Redo
status === TaskStatus.Redo ||
status === TaskStatus.Restart
: status === TaskStatus.Failed ||
status === TaskStatus.Success ||
status === TaskStatus.Complete ||
@ -196,6 +197,7 @@ function handleCheck(
row.taskStatus === TaskStatus.Success ||
row.taskStatus === TaskStatus.Complete ||
row.taskStatus === TaskStatus.Redo ||
row.taskStatus === TaskStatus.Restart ||
(selectedEmployee.value.length > 0 &&
selectedEmployee.value.some((v) => v._template?.id !== row._template?.id))
) {
@ -311,7 +313,7 @@ function disableCheckAll() {
class=""
>
<q-menu :offset="[0, 4]">
<q-list v-if="validate">
<q-list v-if="validate" dense>
<q-item
v-if="
!selectedEmployee.some(
@ -355,6 +357,25 @@ function disableCheckAll() {
></q-icon>
{{ $t(`taskOrder.status.Redo`) }}
</q-item>
<q-item
clickable
v-close-popup
class="items-center"
@click="
$emit('changeAllStatus', {
data: selectedEmployee,
status: TaskStatus.Restart,
})
"
>
<q-icon
style="color: hsl(var(--negative-bg))"
name="mdi-file-remove-outline"
class="q-pr-sm"
size="xs"
></q-icon>
{{ $t(`taskOrder.status.Restart`) }}
</q-item>
</q-list>
<q-list v-if="!validate" dense>
<q-item
@ -445,6 +466,7 @@ function disableCheckAll() {
props.row.taskStatus === TaskStatus.Success ||
props.row.taskStatus === TaskStatus.Complete ||
props.row.taskStatus === TaskStatus.Redo ||
props.row.taskStatus === TaskStatus.Restart ||
(selectedEmployee.length > 0 &&
selectedEmployee.some(
(v) => v._template?.id !== props.row._template?.id,

View file

@ -41,7 +41,8 @@ function inactiveCheck() {
currStatus.value?.value === TaskStatus.Redo ||
currStatus.value?.value === TaskStatus.Success ||
currStatus.value?.value === TaskStatus.Complete ||
currStatus.value?.value === TaskStatus.Canceled
currStatus.value?.value === TaskStatus.Canceled ||
currStatus.value?.value === TaskStatus.Restart
);
}

View file

@ -227,6 +227,7 @@ const messengerListGroup = computed(() =>
});
if (indexUser === -1) {
// If user does not exist in acc, create a new entry
acc.push({
responsibleUser: task.responsibleUser,
list: [
@ -236,27 +237,38 @@ const messengerListGroup = computed(() =>
},
],
});
if (selectedEmployee.value.length < acc.length) {
selectedEmployee.value.push([[]]);
}
}
} else {
const userEntry = acc[indexUser];
if (indexUser !== -1) {
const indexProduct = acc[indexUser].list.findIndex(
// Find product in user's list
const indexProduct = userEntry.list.findIndex(
(v) => v.product.id === task.requestWork.productService.product.id,
);
if (indexProduct === -1) {
acc[indexUser].list.push({
// If product does not exist in user's list, add it
userEntry.list.push({
product: task.requestWork.productService.product,
list: [record],
});
}
if (indexProduct !== -1) {
acc[indexUser].list[indexProduct].list.push(record);
} else {
// Append task to the correct product's list
userEntry.list[indexProduct].list.push(record);
}
}
// Ensure `selectedEmployee.value` grows dynamically
while (selectedEmployee.value.length < acc.length) {
selectedEmployee.value.push([]);
}
acc.forEach((accItem, index) => {
const length = accItem.list.length;
while (selectedEmployee.value[index].length < length) {
selectedEmployee.value[index].push([]);
}
});
}
return acc;
@ -1106,15 +1118,21 @@ watch(
step-on
:checkbox-on="
view === TaskOrderStatus.Validate &&
fullTaskOrder?.userTask.find(
(fullTaskOrder?.userTask.find(
(l) => l.userId === v.responsibleUser.id,
)?.userTaskStatus === UserTaskStatus.Submit
)?.userTaskStatus === UserTaskStatus.Submit ||
fullTaskOrder?.userTask.find(
(l) => l.userId === v.responsibleUser.id,
)?.userTaskStatus === UserTaskStatus.Restart)
"
:check-all="
view === TaskOrderStatus.Validate &&
(view === TaskOrderStatus.Validate &&
fullTaskOrder?.userTask.find(
(l) => l.userId === v.responsibleUser.id,
)?.userTaskStatus === UserTaskStatus.Submit) ||
fullTaskOrder?.userTask.find(
(l) => l.userId === v.responsibleUser.id,
)?.userTaskStatus === UserTaskStatus.Submit
)?.userTaskStatus === UserTaskStatus.Restart
"
:rows="sortList(list)"
@change-all-status="

View file

@ -853,6 +853,21 @@ onMounted(async () => {
:label="$t('dialog.action.close')"
outlined
/>
<SaveButton
v-if="pageState.mode === 'edit'"
:disabled="taskListGroup.length === 0"
@click="(e) => refForm?.submit(e)"
solid
/>
<EditButton
v-if="
pageState.mode === 'info' &&
creditNoteData?.creditNoteStatus === CreditNoteStatus.Waiting
"
class="no-print"
@click="pageState.mode = 'edit'"
solid
/>
<SaveButton
v-if="
!creditNoteData ||
@ -869,21 +884,6 @@ onMounted(async () => {
icon="mdi-account-multiple-check-outline"
solid
/>
<SaveButton
v-if="pageState.mode === 'edit'"
:disabled="taskListGroup.length === 0"
@click="(e) => refForm?.submit(e)"
solid
/>
<EditButton
v-if="
pageState.mode === 'info' &&
creditNoteData?.creditNoteStatus === CreditNoteStatus.Waiting
"
class="no-print"
@click="pageState.mode = 'edit'"
solid
/>
</nav>
</footer>
</div>

View file

@ -1296,7 +1296,10 @@ async function submitAccepted() {
id="btn-close"
@click="closeTab()"
:label="$t('dialog.action.close')"
v-if="pageState.mode === 'info' && closeAble()"
v-if="
(pageState.mode === 'info' || pageState.mode === 'create') &&
closeAble()
"
/>
<div class="row q-gutter-x-sm q-ml-xs">

View file

@ -276,6 +276,24 @@ export const useRequestList = defineStore('request-list', () => {
return false;
}
async function actionRequestWork(
requestDataId: string,
step: number,
body: (Omit<Step, 'step'> & {
requestWorkId: string;
})[],
successAll?: boolean,
) {
const res = await api.put<Step[]>(
`/request-data/${requestDataId}/request-work/step-status/${step}`,
body,
{ params: { successAll } },
);
if (res.status < 400) return res.data;
return null;
}
return {
data,
page,
@ -296,6 +314,8 @@ export const useRequestList = defineStore('request-list', () => {
editStatusRequestWork,
cancelRequest,
actionRequestWork,
};
});