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

This commit is contained in:
Methapon2001 2025-03-10 08:43:22 +07:00
commit 2b35db5683
28 changed files with 1249 additions and 191 deletions

View file

@ -47,7 +47,7 @@ const rand = Math.random();
</script> </script>
<template> <template>
<div <div
class="surface-1 rounded q-pa-sm quo-card bordered column" class="surface-1 rounded q-pa-sm quo-card bordered column full-width"
:class="{ 'urgent-card': urgent }" :class="{ 'urgent-card': urgent }"
:style="{ '--animation-delay': rand + 's' }" :style="{ '--animation-delay': rand + 's' }"
> >
@ -110,8 +110,8 @@ const rand = Math.random();
</nav> </nav>
</header> </header>
<div class="ellipsis q-px-xs"> <div class="text-bold q-px-xs ellipsis full-width">
<b>{{ title || '-' }}</b> {{ title || '-' }}
<q-tooltip anchor="bottom start" self="top left"> <q-tooltip anchor="bottom start" self="top left">
{{ title || '-' }} {{ title || '-' }}
</q-tooltip> </q-tooltip>

View file

@ -18,6 +18,7 @@ const props = withDefaults(
hideEdit?: boolean; hideEdit?: boolean;
page?: number; page?: number;
pageSize?: number; pageSize?: number;
hideBtnPreview?: boolean;
}>(), }>(),
{ {
row: () => [], row: () => [],
@ -127,6 +128,7 @@ defineEmits<{
<q-td class="text-right"> <q-td class="text-right">
<q-btn <q-btn
v-if="!hideBtnPreview"
:id="`btn-preview-${props.row.workName}`" :id="`btn-preview-${props.row.workName}`"
icon="mdi-play-box-outline" icon="mdi-play-box-outline"
size="sm" size="sm"

View file

@ -15,9 +15,7 @@ defineProps<{
:default-opened="defaultOpened" :default-opened="defaultOpened"
> >
<template #header> <template #header>
<span> <slot name="header" />
<slot name="header" />
</span>
</template> </template>
<main class="surface-1 q-pa-md full-width"> <main class="surface-1 q-pa-md full-width">

View file

@ -85,6 +85,10 @@ div.fullscreen.q-drawer__backdrop {
font-family: 'Noto Sans Thai', sans-serif; font-family: 'Noto Sans Thai', sans-serif;
} }
.dp__overlay_cell_active.dp__overlay_cell_pad {
font-family: 'Noto Sans Thai', sans-serif;
}
.disabled, .disabled,
.disabled *, .disabled *,
[disabled], [disabled],

View file

@ -149,6 +149,7 @@ export default {
included: 'Included', included: 'Included',
notIncluded: 'Not Included', notIncluded: 'Not Included',
dueDate: 'Due date', dueDate: 'Due date',
year: 'year',
}, },
menu: { menu: {
@ -241,6 +242,16 @@ export default {
}, },
}, },
noti: {
title: 'Notification',
caption: 'All Notification',
unread: 'Unread',
all: 'All',
read: 'Read',
viewALL: 'View All',
markAsRead: 'Mark as Read',
},
form: { form: {
tm6: { tm6: {
transportation: 'Flight/Vehicle', transportation: 'Flight/Vehicle',
@ -791,7 +802,7 @@ export default {
paySplitMessage: 'Amount to be paid (Baht)', paySplitMessage: 'Amount to be paid (Baht)',
summary: 'Total Summary', summary: 'Total Summary',
periodNo: 'Installment No."', periodNo: 'Installment No.',
amount: 'Amount', amount: 'Amount',
payDueDate: 'Pay Due Date', payDueDate: 'Pay Due Date',
callDueDate: 'Call Due Date', callDueDate: 'Call Due Date',
@ -1141,6 +1152,7 @@ export default {
taskListNotFound: 'Task list cannot be found.', taskListNotFound: 'Task list cannot be found.',
creditNoteNotFound: 'Credit note cannot be found.', creditNoteNotFound: 'Credit note cannot be found.',
debitNoteNotFound: 'Debit note cannot be found.', debitNoteNotFound: 'Debit note cannot be found.',
notificationNotFound: 'Notification cannot be found.',
productGroupIsUsed: 'Product group is in used.', productGroupIsUsed: 'Product group is in used.',
productIsUsed: 'Product is in used.', productIsUsed: 'Product is in used.',
@ -1339,39 +1351,49 @@ export default {
}, },
}, },
report: { report: {
report: { title: 'Report',
title: 'Report', value: 'Value',
view: { customerName: 'Customer Name',
Document: 'Document Status Report', view: {
Invoice: 'Payment Report', Document: 'Document Status Report',
Product: 'Product and Service Report', Invoice: 'Payment Report',
Sale: 'Sales Summary Report', Product: 'Product and Service Report',
}, Sale: 'Sales Summary Report',
document: { Profit: 'Profit and Loss Report',
code: 'Code', },
status: 'Quotation Status', document: {
createAt: 'Created Date', code: 'Code',
updateAt: 'Updated Date', status: 'Quotation Status',
}, createAt: 'Created Date',
table: { updateAt: 'Updated Date',
code: 'Code', },
status: 'Status', table: {
createAt: 'Created Date', code: 'Code',
}, status: 'Status',
product: { createAt: 'Created Date',
did: 'Processed Quantity', },
sale: 'Sold Quantity', product: {
name: 'Product Name', did: 'Processed Quantity',
code: 'Product Code', sale: 'Sold Quantity',
}, name: 'Product Name',
sale: { code: 'Product Code',
byCustomer: 'Sales by Branch', },
byProductGroup: 'Sales by Product Category', sale: {
bySale: 'Sales by Salesperson', byCustomer: 'Sales by Branch',
code: 'Code', byProductGroup: 'Sales by Product Category',
name: 'Name', bySale: 'Sales by Salesperson',
count: 'Sold Quantity', code: 'Code',
}, name: 'Name',
count: 'Sold Quantity',
},
profit: {
byMonth: 'Profit Table by Month',
byYear: 'Profit Table by Year',
month: 'Month',
year: 'Year (AD)',
netProfit: 'Net Profit',
expenses: 'Expenses',
income: 'Income',
}, },
}, },
dashboard: { dashboard: {
@ -1406,4 +1428,19 @@ export default {
caption: 'Based on Tax Invoices', caption: 'Based on Tax Invoices',
}, },
}, },
month: {
1: 'January',
2: 'February',
3: 'March',
4: 'April',
5: 'May',
6: 'June',
7: 'July',
8: 'August',
9: 'September',
10: 'October',
11: 'November',
12: 'December',
},
}; };

View file

@ -149,6 +149,7 @@ export default {
included: 'รวม', included: 'รวม',
notIncluded: 'ไม่รวม', notIncluded: 'ไม่รวม',
dueDate: 'วันครบกำหนด', dueDate: 'วันครบกำหนด',
year: 'ปี',
}, },
menu: { menu: {
@ -241,6 +242,16 @@ export default {
}, },
}, },
noti: {
title: 'การแจ้งเตือน',
caption: 'การแจ้งเตือนทั้งหมด',
unread: 'ยังไม่ได้อ่าน',
all: 'ทั้งหมด',
read: 'อ่าน',
viewAll: 'ดูทั้งหมด',
markAsRead: 'ทำเครื่องหมายว่าอ่านแล้ว',
},
form: { form: {
tm6: { tm6: {
transportation: 'เที่ยวบิน/พาหนะ', transportation: 'เที่ยวบิน/พาหนะ',
@ -1119,6 +1130,7 @@ export default {
taskListNotFound: 'ไม่พบใบสั่งงาน', taskListNotFound: 'ไม่พบใบสั่งงาน',
creditNoteNotFound: 'ไม่พบใบลดหนี้', creditNoteNotFound: 'ไม่พบใบลดหนี้',
debitNoteNotFound: 'ไม่พบใบเพิ่มหนี้', debitNoteNotFound: 'ไม่พบใบเพิ่มหนี้',
notificationNotFound: 'ไม่พบการแจ้งเตือน',
productGroupIsUsed: 'กลุ่มสินค้าและบริการที่ใช้งานอยู่', productGroupIsUsed: 'กลุ่มสินค้าและบริการที่ใช้งานอยู่',
productIsUsed: 'สินค้าและบริการใช้งานอยู่', productIsUsed: 'สินค้าและบริการใช้งานอยู่',
@ -1320,11 +1332,14 @@ export default {
report: { report: {
title: 'รายงาน', title: 'รายงาน',
customerName: 'ชื่อลูกค้า',
value: 'มูลค่า',
view: { view: {
Document: 'รายงานสถานะเอกสาร', Document: 'รายงานสถานะเอกสาร',
Invoice: 'รายงานการชำระเงิน', Invoice: 'รายงานการชำระเงิน',
Product: 'รายงานสินค้าและบริการ', Product: 'รายงานสินค้าและบริการ',
Sale: 'รายงานสรุปยอดขาย', Sale: 'รายงานสรุปยอดขาย',
Profit: 'รายงานกำไรและขาดทุน',
}, },
document: { document: {
code: 'รหัส', code: 'รหัส',
@ -1352,6 +1367,15 @@ export default {
name: 'ชื่อ', name: 'ชื่อ',
count: 'จำนวนที่ขาย', count: 'จำนวนที่ขาย',
}, },
profit: {
byMonth: 'ตารางผลกำไรตามเดือน',
byYear: 'ตารางผลกำไรตามปี',
month: 'เดือน',
year: 'ปี (ค.ศ.)',
netProfit: 'กำไร',
expenses: 'ต้นทุน',
income: 'รายได้',
},
}, },
dashboard: { dashboard: {
title: 'Dashboard', title: 'Dashboard',
@ -1385,4 +1409,19 @@ export default {
caption: 'ตามใบกำกับภาษี', caption: 'ตามใบกำกับภาษี',
}, },
}, },
month: {
1: 'มกราคม',
2: 'กุมภาพันธ์',
3: 'มีนาคม',
4: 'เมษายน',
5: 'พฤษภาคม',
6: 'มิถุนายน',
7: 'กรกฎาคม',
8: 'สิงหาคม',
9: 'กันยายน',
10: 'ตุลาคม',
11: 'พฤศจิกายน',
12: 'ธันวาคม',
},
}; };

View file

@ -1,10 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted } from 'vue'; import { ref, onMounted, computed, reactive } from 'vue';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { useQuasar } from 'quasar'; import { useQuasar } from 'quasar';
import { getUserId, getUsername, logout, getRole } from 'src/services/keycloak'; import { getUserId, getUsername, logout, getRole } from 'src/services/keycloak';
import { Icon } from '@iconify/vue'; import { Icon } from '@iconify/vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import moment from 'moment';
import useLoader from 'stores/loader'; import useLoader from 'stores/loader';
import ProfileMenu from './ProfileMenu.vue'; import ProfileMenu from './ProfileMenu.vue';
@ -17,6 +18,8 @@ import { useConfigStore } from 'src/stores/config';
import { useNavigator } from 'src/stores/navigator'; import { useNavigator } from 'src/stores/navigator';
import { initLang, initTheme, Lang, setLang } from 'src/utils/ui'; import { initLang, initTheme, Lang, setLang } from 'src/utils/ui';
import { baseUrl } from 'stores/utils'; import { baseUrl } from 'stores/utils';
import { useNotification } from 'src/stores/notification';
import NotiDialog from 'src/pages/00_notification/NotiDialog.vue';
const useMyBranch = useMyBranchStore(); const useMyBranch = useMyBranchStore();
const { fetchListMyBranch } = useMyBranch; const { fetchListMyBranch } = useMyBranch;
@ -27,18 +30,14 @@ interface NotificationButton {
active: boolean; active: boolean;
} }
interface Notification {
id: string;
title: string;
content: string;
read: boolean;
}
const $q = useQuasar(); const $q = useQuasar();
const loaderStore = useLoader(); const loaderStore = useLoader();
const navigatorStore = useNavigator(); const navigatorStore = useNavigator();
const notificationStore = useNotification();
const configStore = useConfigStore(); const configStore = useConfigStore();
const { data: notificationData } = storeToRefs(notificationStore);
const { visible } = storeToRefs(loaderStore); const { visible } = storeToRefs(loaderStore);
const { t } = useI18n({ useScope: 'global' }); const { t } = useI18n({ useScope: 'global' });
const userStore = useUserStore(); const userStore = useUserStore();
@ -48,8 +47,9 @@ const canvasModal = ref(false);
const leftDrawerOpen = ref<boolean>(false); const leftDrawerOpen = ref<boolean>(false);
const leftDrawerMini = ref(false); const leftDrawerMini = ref(false);
const filterUnread = ref(false); const unread = computed<number>(
const unread = ref<number>(1); () => notificationData.value.filter((v) => !v.read).length || 0,
);
// const filterRole = ref<string[]>(); // const filterRole = ref<string[]>();
const userImage = ref<string>(); const userImage = ref<string>();
const userGender = ref(''); const userGender = ref('');
@ -65,33 +65,24 @@ const language: {
{ value: Lang.English, label: 'English', icon: 'us', date: 'en-gb' }, { value: Lang.English, label: 'English', icon: 'us', date: 'en-gb' },
]; ];
const notiOpen = ref(false); const state = reactive({
filterUnread: false,
notiOpen: false,
notiDialog: false,
notiId: '',
});
const notiMenu = ref<NotificationButton[]>([ const notiMenu = ref<NotificationButton[]>([
{ {
item: 'ทั้งหมด', item: 'all',
color: 'noti-switch-on', color: 'noti-switch-on',
active: true, active: true,
}, },
{ {
item: 'ยังไม่ได้อ่าน', item: 'unread',
color: 'noti-switch-off', color: 'noti-switch-off',
active: false, active: false,
}, },
]); ]);
const notification = ref<Notification[]>([
{
id: '1',
title: 'Unread',
content: 'Unread',
read: false,
},
{
id: '3',
title: 'Read',
content: 'Already read',
read: true,
},
]);
function setActive(button: NotificationButton) { function setActive(button: NotificationButton) {
notiMenu.value = notiMenu.value.map((current) => ({ notiMenu.value = notiMenu.value.map((current) => ({
@ -99,12 +90,12 @@ function setActive(button: NotificationButton) {
color: current.item !== button.item ? 'noti-switch-off' : 'noti-switch-on', color: current.item !== button.item ? 'noti-switch-off' : 'noti-switch-on',
active: current.item === button.item, active: current.item === button.item,
})); }));
if (button.item === 'ยังไม่ได้อ่าน') { if (button.item === 'unread') {
// noti.value?.result && // noti.value?.result &&
filterUnread.value = true; state.filterUnread = true;
} }
if (button.item === 'ทั้งหมด') { if (button.item === 'all') {
filterUnread.value = false; state.filterUnread = false;
} }
} }
@ -123,12 +114,25 @@ function doLogout() {
}); });
} }
function readNoti(id: string) {
state.notiDialog = true;
state.notiId = id;
}
onMounted(async () => { onMounted(async () => {
initTheme(); initTheme();
initLang(); initLang();
await configStore.getConfig(); await configStore.getConfig();
{
const noti = await notificationStore.getNotificationList();
if (noti) {
notificationData.value = noti.result;
}
}
await fetchListMyBranch(getUserId() ?? ''); await fetchListMyBranch(getUserId() ?? '');
leftDrawerOpen.value = $q.screen.gt.xs ? true : false; leftDrawerOpen.value = $q.screen.gt.xs ? true : false;
@ -278,7 +282,7 @@ onMounted(async () => {
<div class="row q-gutter-x-md items-center" style="margin-left: auto"> <div class="row q-gutter-x-md items-center" style="margin-left: auto">
<!-- notification --> <!-- notification -->
<!-- <q-btn <q-btn
round round
dense dense
flat flat
@ -286,7 +290,7 @@ onMounted(async () => {
:size="$q.screen.lt.sm ? 'sm' : ''" :size="$q.screen.lt.sm ? 'sm' : ''"
:class="{ bordered: $q.dark.isActive, dark: $q.dark.isActive }" :class="{ bordered: $q.dark.isActive, dark: $q.dark.isActive }"
style="color: var(--surface-1)" style="color: var(--surface-1)"
@click="notiOpen = !notiOpen" @click="state.notiOpen = !state.notiOpen"
> >
<q-icon name="mdi-bell" /> <q-icon name="mdi-bell" />
<q-badge v-if="unread !== 0" rounded floating color="negative"> <q-badge v-if="unread !== 0" rounded floating color="negative">
@ -297,10 +301,16 @@ onMounted(async () => {
:offset="[0, 10]" :offset="[0, 10]"
anchor="bottom middle" anchor="bottom middle"
self="top middle" self="top middle"
@before-hide="notiOpen = false" @before-hide="
() => {
state.notiOpen = false;
}
"
> >
<div class="q-px-md q-py-sm row col-12 items-center"> <div class="q-px-md q-py-sm row col-12 items-center">
<div class="text-subtitle1 text-weight-bold">แจงเตอน</div> <div class="text-subtitle1 text-weight-bold">
{{ $t('noti.title') }}
</div>
<q-space /> <q-space />
</div> </div>
<div class="q-px-md q-pb-md q-gutter-x-md"> <div class="q-px-md q-pb-md q-gutter-x-md">
@ -312,22 +322,30 @@ onMounted(async () => {
:flat="!btn.active" :flat="!btn.active"
:unelevated="btn.active" :unelevated="btn.active"
:key="index" :key="index"
:label="btn.item" :label="$t('noti.' + btn.item)"
:class="btn.color" :class="btn.color"
@click="setActive(btn)" @click="setActive(btn)"
/> />
</div> </div>
<q-infinite-scroll :offset="250"> <div style="max-height: 30vh; width: 400px; overflow-y: auto">
<div class="caption cursor-pointer"> <section
v-if="
state.filterUnread
? notificationData.filter((v) => !v.read).length
: notificationData.length
"
class="caption cursor-pointer"
>
<q-item <q-item
v-for="(item, i) in state.filterUnread
? notificationData.filter((v) => !v.read)
: notificationData"
dense dense
clickable clickable
class="q-py-sm" class="q-py-sm"
v-ripple v-ripple
v-for="item in !filterUnread @click="readNoti(item.id)"
? notification :key="i"
: notification.filter((v) => !v.read)"
:key="item.id"
> >
<q-avatar <q-avatar
color="positive" color="positive"
@ -343,12 +361,11 @@ onMounted(async () => {
{{ item.title }} {{ item.title }}
</span> </span>
<span class="block ellipsis full-width text-stone"> <span class="block ellipsis full-width text-stone">
{{ item.content }} {{ item.detail }}
</span> </span>
</div> </div>
<span align="right" class="col text-caption text-stone"> <span align="right" class="col text-caption text-stone">
{{ moment(item.createdAt).fromNow() }} {{ moment(item.createdAt).fromNow() }}
5 s
</span> </span>
<q-tooltip <q-tooltip
anchor="top middle" anchor="top middle"
@ -356,21 +373,29 @@ onMounted(async () => {
:delay="1000" :delay="1000"
:offset="[10, 10]" :offset="[10, 10]"
> >
{{ item.content }} {{ item.detail }}
</q-tooltip> </q-tooltip>
</q-item> </q-item>
</div> </section>
<template v-slot:loading> <section v-else class="text-center q-py-sm">
<div <span class="app-text-muted">
class="text-center q-my-md" {{ $t('general.noData') }}
v-if="noti && noti?.result.length < noti?.total" </span>
> </section>
<q-spinner-dots color="primary" size="40px" /> </div>
</div> <div class="col bordered-t">
</template> <q-btn
</q-infinite-scroll> flat
dense
color="info"
class="full-width"
@click="() => $router.push('/notification')"
>
{{ $t('noti.viewAll') }}
</q-btn>
</div>
</q-menu> </q-menu>
</q-btn> --> </q-btn>
<!-- เปลนนภาษา --> <!-- เปลนนภาษา -->
<q-btn <q-btn
@ -468,6 +493,8 @@ onMounted(async () => {
</template> </template>
</DialogForm> </DialogForm>
<NotiDialog v-model="state.notiDialog" v-model:id="state.notiId" />
<global-loading :visibility="visible" /> <global-loading :visibility="visible" />
</q-layout> </q-layout>
</template> </template>

View file

@ -0,0 +1,243 @@
<script lang="ts" setup>
// NOTE: Library
import { onMounted, reactive, ref } from 'vue';
// NOTE: Components
import NoData from 'src/components/NoData.vue';
import NotiDialog from './NotiDialog.vue';
// NOTE: Stores & Type
import { useNavigator } from 'src/stores/navigator';
import { storeToRefs } from 'pinia';
import { useNotification } from 'src/stores/notification';
import { dateFormatJS } from 'src/utils/datetime';
// NOTE: Variable
const navigatorStore = useNavigator();
const notificationStore = useNotification();
const { data: noti } = storeToRefs(notificationStore);
const state = reactive({
currentTab: 'all',
inputSearch: '',
notiDialog: false,
notiId: '',
});
const pageTabs = [
{ label: 'all', value: 'all' },
{ label: 'unread', value: 'unread' },
];
const selectedNoti = ref<string[]>([]);
function toggleSelection(id: string, checkAll: boolean = false) {
const index = selectedNoti.value.indexOf(id);
if (checkAll) {
if (selectedNoti.value.length === noti.value.length) {
selectedNoti.value = [];
} else {
selectedNoti.value = [...noti.value.map((v) => v.id)];
}
return;
}
if (index > -1) {
selectedNoti.value.splice(index, 1);
} else {
selectedNoti.value.push(id);
}
}
async function fetchNoti() {
const res = await notificationStore.getNotificationList();
if (res) {
noti.value = res.result;
}
}
async function markAsRead() {
const res = await notificationStore.markReadNotification(selectedNoti.value);
if (res) {
await fetchNoti();
selectedNoti.value = [];
}
}
async function deleteNoti() {
const res = await notificationStore.deleteMultiNotification(
selectedNoti.value,
);
if (res) {
await fetchNoti();
selectedNoti.value = [];
}
}
function readNoti(id: string) {
state.notiId = id;
state.notiDialog = true;
}
onMounted(async () => {
navigatorStore.current.title = 'noti.title';
navigatorStore.current.path = [{ text: 'noti.caption', i18n: true }];
await fetchNoti();
});
</script>
<template>
<main class="column full-height no-wrap">
<div class="surface-1 col bordered rounded column">
<div class="q-px-lg q-py-xs row items-center">
<q-checkbox
size="xs"
:model-value="selectedNoti.length === noti.length"
class="q-px-sm"
@click="toggleSelection('', true)"
/>
<q-btn
v-if="selectedNoti.length === 0"
icon="mdi-refresh"
rounded
flat
dense
size="xs"
class="app-text-muted-2 q-ml-sm q-mt-xs"
@click="async () => await fetchNoti()"
>
<q-tooltip>Refresh</q-tooltip>
</q-btn>
<q-btn
v-if="selectedNoti.length > 0"
icon="mdi-trash-can-outline"
rounded
flat
dense
size="xs"
class="app-text-muted-2 q-ml-sm"
@click="async () => await deleteNoti()"
>
<q-tooltip>{{ $t('general.delete') }}</q-tooltip>
</q-btn>
<q-btn
v-if="selectedNoti.length > 0"
icon="mdi-email-open-outline"
rounded
flat
dense
size="xs"
class="app-text-muted-2 q-mx-sm"
@click="async () => await markAsRead()"
>
<q-tooltip>{{ $t('noti.markAsRead') }}</q-tooltip>
</q-btn>
<span v-if="selectedNoti.length" class="text-caption app-text-muted-2">
{{
$t('general.selected', {
number: selectedNoti.length,
msg: $t('noti.title'),
})
}}
</span>
</div>
<q-tabs
inline-label
mobile-arrows
dense
v-model="state.currentTab"
align="left"
class="full-width bordered-b"
active-color="info"
>
<q-tab
v-for="tab in pageTabs"
:name="tab.value"
:key="tab.value"
@click="
() => {
state.currentTab = tab.value;
state.inputSearch = '';
}
"
>
<div
class="row text-capitalize"
:class="
state.currentTab === tab.value ? 'text-bold' : 'app-text-muted'
"
>
{{ $t(`noti.${tab.label}`) }}
<q-badge
rounded
class="q-ml-md"
style="background: hsl(var(--info-bg))"
>
{{
tab.value === 'all'
? noti.length
: noti.filter((v) => !v.read).length
}}
</q-badge>
</div>
</q-tab>
</q-tabs>
<section class="scroll full-height col">
<article
v-if="
state.currentTab === 'unread'
? noti.filter((v) => !v.read).length > 0
: noti.length > 0
"
class="q-pa-md q-gutter-sm"
>
<q-item
v-for="(n, i) in state.currentTab === 'unread'
? noti.filter((v) => !v.read)
: noti"
:key="i"
clickable
:class="{ 'surface-3': !n.read }"
class="q-py-sm q-px-md rounded row no-wrap items-center"
@click.stop="readNoti(n.id)"
>
<!-- <div
class="rounded"
:style="`background: hsl(var(--info-bg)/${n.read ? 0 : 1}); width: 6px; height: 6px`"
/> -->
<q-checkbox
:model-value="selectedNoti.includes(n.id)"
size="xs"
class="q-pr-lg"
@click.stop="toggleSelection(n.id)"
/>
<div class="column">
{{ n.title }}
<span class="text-caption app-text-muted">
{{ n.detail }}
</span>
</div>
<div class="q-ml-auto">
{{
dateFormatJS({
date: n.createdAt,
dayStyle: 'numeric',
monthStyle: '2-digit',
withTime: true,
})
}}
</div>
</q-item>
</article>
<div v-else class="full-height flex items-center justify-center">
<NoData />
</div>
</section>
</div>
</main>
<NotiDialog v-model="state.notiDialog" v-model:id="state.notiId" />
</template>
<style scoped></style>

View file

@ -0,0 +1,69 @@
<script lang="ts" setup>
import DialogFormContainer from 'src/components/dialog/DialogFormContainer.vue';
import DialogHeader from 'src/components/dialog/DialogHeader.vue';
import { CancelButton } from 'src/components/button';
import { ref } from 'vue';
import { Notification, useNotification } from 'src/stores/notification';
import { dateFormatJS } from 'src/utils/datetime';
const notificationStore = useNotification();
const open = defineModel<boolean>({ default: false, required: true });
const notiId = defineModel<string>('id', { default: '', required: true });
const noti = ref<Notification>();
function close() {
open.value = false;
}
async function fetchNoti() {
const res = await notificationStore.getNotification(notiId.value);
if (res) {
noti.value = res;
}
}
</script>
<template>
<DialogFormContainer
width="60vw"
height="350px"
v-model="open"
v-on:open="fetchNoti"
>
<template #header>
<DialogHeader :title="$t('noti.title')" />
</template>
<section v-if="noti" class="q-pa-md col full-width">
<article
class="surface-1 rounded bordered q-pa-md full-height full-width"
>
<div class="text-bold">
{{ noti.title }}
</div>
<div class="text-caption app-text-muted">
{{
dateFormatJS({
date: noti.createdAt,
monthStyle: 'long',
withTime: true,
})
}}
</div>
<q-separator spaced="md" />
<div class="text-caption">
{{ noti.detail }}
</div>
</article>
</section>
<template #footer>
<CancelButton
class="q-ml-auto"
:label="$t('general.close')"
outlined
@click="close"
/>
</template>
</DialogFormContainer>
</template>

View file

@ -641,6 +641,7 @@ async function storeDataLocal(id: string) {
:visible-columns="pageState.fieldSelected" :visible-columns="pageState.fieldSelected"
:grid="pageState.gridView" :grid="pageState.gridView"
:hide-edit="pageState.currentTab !== 'Issued'" :hide-edit="pageState.currentTab !== 'Issued'"
:hide-btn-preview="pageState.currentTab === 'PaymentSuccess'"
@preview="(id: any) => storeDataLocal(id)" @preview="(id: any) => storeDataLocal(id)"
@view=" @view="
(item) => { (item) => {
@ -667,6 +668,7 @@ async function storeDataLocal(id: string) {
class="col" class="col"
hide-kebab-delete hide-kebab-delete
:hide-kebab-edit="!(pageState.currentTab === 'Issued')" :hide-kebab-edit="!(pageState.currentTab === 'Issued')"
:hide-preview="pageState.currentTab === 'PaymentSuccess'"
:urgent="item.row.urgent" :urgent="item.row.urgent"
:code="item.row.code" :code="item.row.code"
:title="item.row.workName" :title="item.row.workName"

View file

@ -32,6 +32,8 @@ const prop = defineProps<{
isDebitNote?: boolean; isDebitNote?: boolean;
}>(); }>();
const firstCodePayment = defineModel<string>('firstCodePayment');
const refQFile = ref<InstanceType<typeof QFile>[]>([]); const refQFile = ref<InstanceType<typeof QFile>[]>([]);
const refQMenu = ref<InstanceType<typeof QMenu>[]>([]); const refQMenu = ref<InstanceType<typeof QMenu>[]>([]);
const paymentData = ref<QuotationPaymentData[]>([]); const paymentData = ref<QuotationPaymentData[]>([]);
@ -206,10 +208,15 @@ onMounted(async () => {
}); });
if (ret) { if (ret) {
paymentData.value = ret.result; paymentData.value = ret.result;
slipFile.value = paymentData.value.map((v) => ({ slipFile.value = paymentData.value.map((v, i) => {
paymentId: v.id, if (i === 0) {
data: [], firstCodePayment.value = v.code;
})); }
return {
paymentId: v.id,
data: [],
};
});
} }
}); });
</script> </script>

View file

@ -196,7 +196,7 @@ const selectedWorkerItem = computed(() => {
]; ];
}); });
const workerList = ref<Employee[]>([]); const workerList = ref<Employee[]>([]);
const firstCodePayment = ref('');
const selectedProductGroup = ref(''); const selectedProductGroup = ref('');
const selectedInstallmentNo = ref<number[]>([]); const selectedInstallmentNo = ref<number[]>([]);
const installmentAmount = ref<number>(0); const installmentAmount = ref<number>(0);
@ -1093,12 +1093,14 @@ watch(
// } // }
function storeDataLocal() { function storeDataLocal() {
quotationFormData.value.productServiceList = productServiceList.value; quotationFormData.value.productServiceList = productService.value;
localStorage.setItem( localStorage.setItem(
'quotation-preview', 'quotation-preview',
JSON.stringify({ JSON.stringify({
data: { data: {
codeInvoice: code.value,
codePayment: firstCodePayment.value,
...quotationFormData.value, ...quotationFormData.value,
productServiceList: productService.value, productServiceList: productService.value,
}, },
@ -1939,6 +1941,7 @@ function covertToNode() {
view !== View.Complete view !== View.Complete
" "
:data="quotationFormState.source" :data="quotationFormState.source"
v-model:first-code-payment="firstCodePayment"
@fetch-status=" @fetch-status="
() => { () => {
fetchQuotation(); fetchQuotation();
@ -2105,6 +2108,8 @@ function covertToNode() {
installmentAmount = props.row.amount; installmentAmount = props.row.amount;
view = View.Invoice; view = View.Invoice;
console.log(code);
} }
} }
" "
@ -2289,6 +2294,7 @@ function covertToNode() {
<MainButton <MainButton
solid solid
icon="mdi-account-multiple-check-outline" icon="mdi-account-multiple-check-outline"
class="q-ml-sm"
color="207 96% 32%" color="207 96% 32%"
id="btn-select-invoice" id="btn-select-invoice"
@click=" @click="

View file

@ -86,10 +86,22 @@ const summaryPrice = ref<SummaryPrice>({
finalPrice: 0, finalPrice: 0,
}); });
async function fetchQuotationById(id: string) { async function fetchQuotationById(id: string, codeInvoice: string) {
const res = await quotationStore.getQuotation(id); const res = await quotationStore.getQuotation(id);
if (res) {
const installmentNo = res.paySplit.find(
(v) => codeInvoice === v.invoice?.code,
)?.no;
if (res) data.value = res; data.value = {
...res,
productServiceList: !!installmentNo
? res.productServiceList.filter(
(v) => installmentNo === v.installmentNo,
)
: res.productServiceList,
};
}
} }
async function getAttachment(quotationId: string) { async function getAttachment(quotationId: string) {
@ -190,7 +202,8 @@ onMounted(async () => {
if (data.value) { if (data.value) {
if (!!data.value.id) { if (!!data.value.id) {
await getAttachment(data.value.id); await getAttachment(data.value.id);
await fetchQuotationById(data.value.id); if (parsed.data.fetch)
await fetchQuotationById(data.value.id, parsed.data.codeInvoice);
} }
const resCustomerBranch = await customerStore.getBranchById( const resCustomerBranch = await customerStore.getBranchById(
@ -203,8 +216,15 @@ onMounted(async () => {
agentPrice.value = data.value.agentPrice || parsed?.meta?.agentPrice; agentPrice.value = data.value.agentPrice || parsed?.meta?.agentPrice;
const currentCode =
view.value === View.Invoice
? parsed.data.codeInvoice
: view.value === View.Payment
? parsed.data.codePayment
: (parsed?.meta?.source?.code ?? data.value?.code);
details.value = { details.value = {
code: parsed?.meta?.source?.code ?? data.value?.code, code: currentCode,
createdAt: createdAt:
parsed?.meta?.source?.createdAt ?? parsed?.meta?.source?.createdAt ??
new Date(data.value?.createdAt || ''), new Date(data.value?.createdAt || ''),

View file

@ -88,12 +88,16 @@ function triggerView(opts: { quotationId: string }) {
window.open(url.toString(), '_blank'); window.open(url.toString(), '_blank');
} }
function viewDocExample(quotationId: string) { function viewDocExample(quotationId: string, codeInvoice: string) {
console.log(codeInvoice);
localStorage.setItem( localStorage.setItem(
'quotation-preview', 'quotation-preview',
JSON.stringify({ JSON.stringify({
data: { data: {
id: quotationId, id: quotationId,
codeInvoice,
fetch: true,
}, },
}), }),
); );
@ -346,7 +350,7 @@ watch(
:rows="data" :rows="data"
:grid="pageState.gridView" :grid="pageState.gridView"
@view="(quotationId) => triggerView({ quotationId })" @view="(quotationId) => triggerView({ quotationId })"
@preview="(quotationId) => viewDocExample(quotationId)" @preview="(item) => viewDocExample(item.quotation.id, item.code)"
> >
<template #grid="{ item }"> <template #grid="{ item }">
<div class="col-md-4 col-sm-6 col-12"> <div class="col-md-4 col-sm-6 col-12">
@ -401,9 +405,7 @@ watch(
() => triggerView({ quotationId: item.row.quotation.id }) () => triggerView({ quotationId: item.row.quotation.id })
" "
@preview=" @preview="
() => { () => viewDocExample(item.row.quotation.id, item.row.code)
viewDocExample(item.row.quotation.id);
}
" "
/> />
</div> </div>

View file

@ -41,7 +41,7 @@ withDefaults(
defineEmits<{ defineEmits<{
(e: 'view', id: string): void; (e: 'view', id: string): void;
(e: 'preview', id: string): void; (e: 'preview', data: Invoice): void;
(e: 'edit', data: T, index: number): void; (e: 'edit', data: T, index: number): void;
(e: 'delete', data: T, index: number): void; (e: 'delete', data: T, index: number): void;
(e: 'download', data: T, index: number): void; (e: 'download', data: T, index: number): void;
@ -118,7 +118,7 @@ defineEmits<{
icon="mdi-play-box-outline" icon="mdi-play-box-outline"
size="12px" size="12px"
:title="$t('preview.doc')" :title="$t('preview.doc')"
@click.stop="$emit('preview', props.row.quotation.id)" @click.stop="$emit('preview', props.row)"
/> />
<q-btn <q-btn

View file

@ -1,6 +1,6 @@
<script lang="ts" setup> <script lang="ts" setup>
// NOTE: Library // NOTE: Library
import { computed, onMounted, reactive, watch } from 'vue'; import { ref, computed, onMounted, reactive, watch } from 'vue';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { getRole } from 'src/services/keycloak'; import { getRole } from 'src/services/keycloak';
@ -19,27 +19,58 @@ import {
colReport, colReport,
colReportProduct, colReportProduct,
colReportSale, colReportSale,
colProfit,
colProfitByMoth,
colProfitByYear,
} from './constants'; } from './constants';
import useFlowStore from 'src/stores/flow'; import useFlowStore from 'src/stores/flow';
import { useReportStore } from 'src/stores/report'; import { useReportStore } from 'src/stores/report';
import BadgeComponent from 'src/components/BadgeComponent.vue'; import BadgeComponent from 'src/components/BadgeComponent.vue';
import Expansion from 'src/components/14_report/Expansion.vue'; import Expansion from 'src/components/14_report/Expansion.vue';
import { SaveButton } from 'src/components/button';
import { ReportProfit } from 'src/stores/report/types';
// NOTE: Variable // NOTE: Variable
const navigatorStore = useNavigator(); const navigatorStore = useNavigator();
const reportStore = useReportStore(); const reportStore = useReportStore();
const flow = useFlowStore(); const flow = useFlowStore();
const dataReportProfitByYears = ref<ReportProfit['dataset']>([]);
const pastYears = ref<number>(5);
const optionsPastYears = ref<number[]>([5, 10, 20, 30, 50, 80]);
const { const {
dataReportQuotation, dataReportQuotation,
dataReportInvoice, dataReportInvoice,
dataReportReceipt, dataReportReceipt,
dataReportSale, dataReportSale,
dataReportProduct, dataReportProduct,
dataReportProfit,
} = storeToRefs(reportStore); } = storeToRefs(reportStore);
const userRoles = computed(() => getRole() || []); const userRoles = computed(() => getRole() || []);
const combinedProfitYear = computed(() => {
return (
dataReportProfitByYears.value?.reduce<ReportProfit['dataset']>(
(acc, val) => {
let find = acc.find((item) => item.year === val.year);
if (find) {
find.expenses += val.expenses;
find.netProfit += val.netProfit;
find.income += val.income;
} else {
acc.push({ ...val });
}
return acc;
},
[],
) ?? []
);
});
async function fetchReportQuotation() { async function fetchReportQuotation() {
dataReportQuotation.value = (await reportStore.getReportQuotation()) || []; dataReportQuotation.value = (await reportStore.getReportQuotation()) || [];
} }
@ -57,6 +88,16 @@ async function fetchReportProduct() {
dataReportProduct.value = (await reportStore.getReportProduct()) || []; dataReportProduct.value = (await reportStore.getReportProduct()) || [];
} }
async function fetchReportProfit() {
dataReportProfit.value = (await reportStore.getReportProfit()) || undefined;
dataReportProfitByYears.value = dataReportProfit.value?.dataset || [];
}
async function fetchReportProfitByYears(years: number) {
const res = (await reportStore.getReportProfit({ years })) || undefined;
dataReportProfitByYears.value = res?.dataset || [];
}
onMounted(async () => { onMounted(async () => {
navigatorStore.current.title = 'report.title'; navigatorStore.current.title = 'report.title';
navigatorStore.current.path = [{ text: '' }]; navigatorStore.current.path = [{ text: '' }];
@ -64,7 +105,7 @@ onMounted(async () => {
const isExecutive = computed(() => { const isExecutive = computed(() => {
const roles = userRoles.value; const roles = userRoles.value;
return roles.includes('executive'); return roles.includes('executive') || roles.includes('system');
}); });
const filteredTabs = computed(() => { const filteredTabs = computed(() => {
@ -78,7 +119,7 @@ const filteredTabs = computed(() => {
}); });
const pageState = reactive({ const pageState = reactive({
currentTab: isExecutive.value ? ViewMode.Product : ViewMode.Document, currentTab: ViewMode.Document,
}); });
async function fetchReportTab() { async function fetchReportTab() {
@ -101,6 +142,10 @@ async function fetchReportTab() {
await fetchReportSale(); await fetchReportSale();
break; break;
} }
case ViewMode.Profit: {
await fetchReportProfit();
break;
}
} }
} }
@ -111,6 +156,10 @@ onMounted(async () => {
watch([() => pageState.currentTab], async () => { watch([() => pageState.currentTab], async () => {
await fetchReportTab(); await fetchReportTab();
}); });
watch([() => pastYears.value], async () => {
await fetchReportProfitByYears(pastYears.value);
});
</script> </script>
<template> <template>
@ -166,11 +215,33 @@ watch([() => pageState.currentTab], async () => {
<!-- Quotatio --> <!-- Quotatio -->
<Expansion default-opened> <Expansion default-opened>
<template #header>{{ $t('quotation.title') }}</template> <template #header>
<div class="flex full-width items-center">
{{ $t('quotation.title') }}
<SaveButton
style="margin-left: auto"
:icon="'material-symbols:download'"
:label="$t('general.download')"
@click.stop="reportStore.downloadReportQuotation()"
/>
</div>
</template>
<template #main> <template #main>
<TableReport <TableReport
:row="dataReportQuotation" :row="
dataReportQuotation.map((v) => ({
...v,
name:
v.customerBranch.customer.customerType === 'CORP'
? $i18n.locale === 'eng'
? v.customerBranch.registerNameEN
: v.customerBranch.registerName
: $i18n.locale === 'eng'
? `${v.customerBranch.firstNameEN} ${v.customerBranch.lastNameEN}`
: `${v.customerBranch.firstName} ${v.customerBranch.lastName}`,
}))
"
:columns="colReportQuotation" :columns="colReportQuotation"
> >
<template #status="{ item }"> <template #status="{ item }">
@ -185,9 +256,34 @@ watch([() => pageState.currentTab], async () => {
<!-- Invoice --> <!-- Invoice -->
<Expansion default-opened> <Expansion default-opened>
<template #header>{{ $t('invoice.title') }}</template> <template #header>
<div class="flex full-width items-center">
{{ $t('invoice.title') }}
<SaveButton
style="margin-left: auto"
:icon="'material-symbols:download'"
:label="$t('general.download')"
@click.stop="reportStore.downloadReportInvoice()"
/>
</div>
</template>
<template #main> <template #main>
<TableReport :row="dataReportInvoice" :columns="colReport"> <TableReport
:row="
dataReportInvoice.map((v) => ({
...v,
name:
v.customerBranch.customer.customerType === 'CORP'
? $i18n.locale === 'eng'
? v.customerBranch.registerNameEN
: v.customerBranch.registerName
: $i18n.locale === 'eng'
? `${v.customerBranch.firstNameEN} ${v.customerBranch.lastNameEN}`
: `${v.customerBranch.firstName} ${v.customerBranch.lastName}`,
}))
"
:columns="colReport"
>
<template #status="{ item }"> <template #status="{ item }">
<BadgeComponent <BadgeComponent
:title="$t(`invoice.status.${item.row.status}`)" :title="$t(`invoice.status.${item.row.status}`)"
@ -200,9 +296,34 @@ watch([() => pageState.currentTab], async () => {
<!-- Receipt --> <!-- Receipt -->
<Expansion default-opened> <Expansion default-opened>
<template #header>{{ $t('receipt.title') }}</template> <template #header>
<div class="flex full-width items-center">
{{ $t('receipt.title') }}
<SaveButton
style="margin-left: auto"
:icon="'material-symbols:download'"
:label="$t('general.download')"
@click.stop="reportStore.downloadReportReceipt()"
/>
</div>
</template>
<template #main> <template #main>
<TableReport :row="dataReportReceipt" :columns="colReport"> <TableReport
:row="
dataReportReceipt.map((v) => ({
...v,
name:
v.customerBranch.customer.customerType === 'CORP'
? $i18n.locale === 'eng'
? v.customerBranch.registerNameEN
: v.customerBranch.registerName
: $i18n.locale === 'eng'
? `${v.customerBranch.firstNameEN} ${v.customerBranch.lastNameEN}`
: `${v.customerBranch.firstName} ${v.customerBranch.lastName}`,
}))
"
:columns="colReport"
>
<template #status="{ item }"> <template #status="{ item }">
<BadgeComponent <BadgeComponent
:title="$t(`invoice.status.${item.row.status}`)" :title="$t(`invoice.status.${item.row.status}`)"
@ -217,9 +338,34 @@ watch([() => pageState.currentTab], async () => {
<template v-if="pageState.currentTab === ViewMode.Invoice"> <template v-if="pageState.currentTab === ViewMode.Invoice">
<Expansion default-opened> <Expansion default-opened>
<template #header>{{ $t('invoice.title') }}</template> <template #header>
<div class="flex full-width items-center">
{{ $t('invoice.title') }}
<SaveButton
style="margin-left: auto"
:icon="'material-symbols:download'"
:label="$t('general.download')"
@click.stop="reportStore.downloadReportInvoice()"
/>
</div>
</template>
<template #main> <template #main>
<TableReport :row="dataReportInvoice" :columns="colReport"> <TableReport
:row="
dataReportInvoice.map((v) => ({
...v,
name:
v.customerBranch.customer.customerType === 'CORP'
? $i18n.locale === 'eng'
? v.customerBranch.registerNameEN
: v.customerBranch.registerName
: $i18n.locale === 'eng'
? `${v.customerBranch.firstNameEN} ${v.customerBranch.lastNameEN}`
: `${v.customerBranch.firstName} ${v.customerBranch.lastName}`,
}))
"
:columns="colReport"
>
<template #status="{ item }"> <template #status="{ item }">
<BadgeComponent <BadgeComponent
:title="$t(`invoice.status.${item.row.status}`)" :title="$t(`invoice.status.${item.row.status}`)"
@ -233,7 +379,17 @@ watch([() => pageState.currentTab], async () => {
<template v-if="pageState.currentTab === ViewMode.Product"> <template v-if="pageState.currentTab === ViewMode.Product">
<Expansion default-opened> <Expansion default-opened>
<template #header>{{ $t('productService.title') }}</template> <template #header>
<div class="flex full-width items-center">
{{ $t('productService.title') }}
<SaveButton
style="margin-left: auto"
:icon="'material-symbols:download'"
:label="$t('general.download')"
@click.stop="reportStore.downloadReportInvoice()"
/>
</div>
</template>
<template #main> <template #main>
<TableReport <TableReport
:row="dataReportProduct" :row="dataReportProduct"
@ -245,7 +401,19 @@ watch([() => pageState.currentTab], async () => {
<template v-if="pageState.currentTab === ViewMode.Sale"> <template v-if="pageState.currentTab === ViewMode.Sale">
<div class="q-gutter-y-md"> <div class="q-gutter-y-md">
<Expansion default-opened> <Expansion default-opened>
<template #header>{{ $t('report.sale.byCustomer') }}</template> <template #header>
<div class="flex full-width items-center">
{{ $t('report.sale.byCustomer') }}
<SaveButton
style="margin-left: auto"
:icon="'material-symbols:download'"
:label="$t('general.download')"
@click.stop="
reportStore.downloadReportSale('by-customer')
"
/>
</div>
</template>
<template #main> <template #main>
<TableReport <TableReport
:row=" :row="
@ -254,7 +422,9 @@ watch([() => pageState.currentTab], async () => {
code: v.code, code: v.code,
name: name:
v.customer.customerType === 'CORP' v.customer.customerType === 'CORP'
? v.customerName ? $i18n.locale === 'eng'
? v.registerNameEN
: v.registerName
: $i18n.locale === 'eng' : $i18n.locale === 'eng'
? `${v.firstNameEN} ${v.lastNameEN}` ? `${v.firstNameEN} ${v.lastNameEN}`
: `${v.firstName} ${v.lastName}`, : `${v.firstName} ${v.lastName}`,
@ -273,7 +443,17 @@ watch([() => pageState.currentTab], async () => {
</Expansion> </Expansion>
<Expansion default-opened> <Expansion default-opened>
<template #header> <template #header>
{{ $t('report.sale.byProductGroup') }} <div class="flex full-width items-center">
{{ $t('report.sale.byProductGroup') }}
<SaveButton
style="margin-left: auto"
:icon="'material-symbols:download'"
:label="$t('general.download')"
@click.stop="
reportStore.downloadReportSale('by-product-group')
"
/>
</div>
</template> </template>
<template #main> <template #main>
<TableReport <TableReport
@ -295,7 +475,17 @@ watch([() => pageState.currentTab], async () => {
</template> </template>
</Expansion> </Expansion>
<Expansion default-opened> <Expansion default-opened>
<template #header>{{ $t('report.sale.bySale') }}</template> <template #header>
<div class="flex full-width items-center">
{{ $t('report.sale.bySale') }}
<SaveButton
style="margin-left: auto"
:icon="'material-symbols:download'"
:label="$t('general.download')"
@click.stop="reportStore.downloadReportSale('by-sale')"
/>
</div>
</template>
<template #main> <template #main>
<TableReport <TableReport
:row=" :row="
@ -353,6 +543,99 @@ watch([() => pageState.currentTab], async () => {
</Expansion> </Expansion>
</div> </div>
</template> </template>
<template v-if="pageState.currentTab === ViewMode.Profit">
<div class="q-gutter-y-md">
<Expansion default-opened>
<template #header>
<div class="flex full-width items-center">
{{ $t('report.profit.byMonth') }}
</div>
</template>
<template #main>
<TableReport
:row="dataReportProfit?.dataset"
:columns="colProfitByMoth"
>
<template #title="{ item }">
{{ $t(`month.${item.row.month}`) }}
</template>
</TableReport>
</template>
</Expansion>
<Expansion default-opened>
<template #header>
<div class="flex full-width items-center">
{{ $t('report.profit.byYear') }}
</div>
</template>
<template #main>
<div class="q-gutter-y-md">
<q-select
style="max-width: 150px"
outlined
use-input
fill-input
emit-value
map-options
hide-selected
hide-bottom-space
input-debounce="0"
option-value="value"
option-label="label"
dense
v-model="pastYears"
:options="
optionsPastYears.map((v) => ({
value: v,
label: `${v} ${$t('general.year')}`,
}))
"
label="เลือกปีย้อนหลัง"
></q-select>
<TableReport
:row="combinedProfitYear"
:columns="colProfitByYear"
></TableReport>
</div>
</template>
</Expansion>
<Expansion default-opened>
<template #header>
<div class="flex full-width items-center">
{{ $t('report.view.Profit') }}
</div>
</template>
<template #main>
<TableReport
:row="[
{
title: $t('report.profit.netProfit'),
amount: dataReportProfit?.netProfit || 0,
},
{
title: $t('report.profit.expenses'),
amount: dataReportProfit?.expenses || 0,
},
{
title: $t('report.profit.income'),
amount: dataReportProfit?.income || 0,
},
]"
:columns="colProfit"
hide-header
hide-bottom
></TableReport>
</template>
</Expansion>
</div>
</template>
</article> </article>
</div> </div>
</section> </section>

View file

@ -5,6 +5,8 @@ const prop = withDefaults(
defineProps<{ defineProps<{
row: QTableProps['rows']; row: QTableProps['rows'];
columns: QTableProps['columns']; columns: QTableProps['columns'];
hideHeader?: boolean;
hideBottom?: boolean;
}>(), }>(),
{ {
row: () => [], row: () => [],
@ -22,6 +24,8 @@ const prop = withDefaults(
})) }))
" "
:columns :columns
:hideHeader
:hideBottom
bordered bordered
flat flat
selection="multiple" selection="multiple"
@ -32,7 +36,12 @@ const prop = withDefaults(
style="background-color: hsla(var(--info-bg) / 0.07)" style="background-color: hsla(var(--info-bg) / 0.07)"
:props="props" :props="props"
> >
<q-th v-for="col in columns" :key="col.name" :props="props"> <q-th
v-for="col in columns"
style="text-align: center"
:key="col.name"
:props="props"
>
{{ $t(col.label) }} {{ $t(col.label) }}
</q-th> </q-th>
</q-tr> </q-tr>

View file

@ -14,6 +14,7 @@ export enum ViewMode {
Receipt = 'receipt', Receipt = 'receipt',
Product = 'product', Product = 'product',
Sale = 'sale', Sale = 'sale',
Profit = 'profit',
} }
type ColumnsSale = { type ColumnsSale = {
@ -28,6 +29,25 @@ type ColumnsBySale = ColumnsSale & {
gender?: string; gender?: string;
}; };
type ColumnsProfix = {
title: string;
amount: number;
};
type ColumnsProfixByMonth = {
month: string;
netProfit: number;
expenses: number;
income: number;
};
type ColumnsProfixByYear = Omit<ColumnsProfixByMonth, 'moth'> & {
year: string;
netProfit: number;
expenses: number;
income: number;
};
export const colReportQuotation = [ export const colReportQuotation = [
{ {
name: 'code', name: 'code',
@ -41,6 +61,18 @@ export const colReportQuotation = [
label: 'report.document.status', label: 'report.document.status',
field: '', field: '',
}, },
{
name: 'customer',
align: 'left',
label: 'report.customerName',
field: 'name',
},
{
name: 'amount',
align: 'left',
label: 'report.value',
field: (data: ReportQuotation) => formatNumberDecimal(data.amount, 2),
},
{ {
name: 'createAt', name: 'createAt',
align: 'center', align: 'center',
@ -68,6 +100,18 @@ export const colReport = [
label: 'report.table.status', label: 'report.table.status',
field: '', field: '',
}, },
{
name: 'customer',
align: 'left',
label: 'report.customerName',
field: 'name',
},
{
name: 'amount',
align: 'left',
label: 'report.value',
field: (data: Report) => formatNumberDecimal(data.amount, 2),
},
{ {
name: 'createAt', name: 'createAt',
align: 'center', align: 'center',
@ -145,9 +189,81 @@ export const colReportBySale = [
}, },
] as const satisfies QTableProps['columns']; ] as const satisfies QTableProps['columns'];
export const colProfit = [
{
name: 'title',
align: 'left',
label: '',
field: (data: ColumnsProfix) => data.title,
},
{
name: 'amount',
align: 'left',
label: '',
field: (data: ColumnsProfix) => formatNumberDecimal(data.amount, 2),
},
] as const satisfies QTableProps['columns'];
export const colProfitByMoth = [
{
name: '#title',
align: 'left',
label: 'report.profit.month',
field: '',
},
{
name: 'netProfit',
align: 'left',
label: 'report.profit.netProfit',
field: (data: ColumnsProfixByMonth) =>
formatNumberDecimal(data.netProfit, 2),
},
{
name: 'expenses',
align: 'left',
label: 'report.profit.expenses',
field: (data: ColumnsProfixByMonth) =>
formatNumberDecimal(data.expenses, 2),
},
{
name: 'income',
align: 'left',
label: 'report.profit.income',
field: (data: ColumnsProfixByMonth) => formatNumberDecimal(data.income, 2),
},
] as const satisfies QTableProps['columns'];
export const colProfitByYear = [
{
name: 'title',
align: 'left',
label: 'report.profit.year',
field: (data: ColumnsProfixByYear) => data.year,
},
{
name: 'netProfit',
align: 'left',
label: 'report.profit.netProfit',
field: (data: ColumnsProfixByYear) =>
formatNumberDecimal(data.netProfit, 2),
},
{
name: 'expenses',
align: 'left',
label: 'report.profit.expenses',
field: (data: ColumnsProfixByYear) => formatNumberDecimal(data.expenses, 2),
},
{
name: 'income',
align: 'left',
label: 'report.profit.income',
field: (data: ColumnsProfixByYear) => formatNumberDecimal(data.income, 2),
},
] as const satisfies QTableProps['columns'];
export const pageTabs = [ export const pageTabs = [
{ label: 'Document', value: ViewMode.Document, by: ['user'] }, { label: 'Document', value: ViewMode.Document, by: ['user'] },
{ label: 'Invoice', value: ViewMode.Invoice, by: ['user'] }, { label: 'Invoice', value: ViewMode.Invoice, by: ['user'] },
{ label: 'Product', value: ViewMode.Product, by: ['user'] }, { label: 'Product', value: ViewMode.Product, by: ['user'] },
{ label: 'Sale', value: ViewMode.Sale, by: ['admin'] }, { label: 'Sale', value: ViewMode.Sale, by: ['admin'] },
{ label: 'Profit', value: ViewMode.Profit, by: ['admin'] },
]; ];

View file

@ -1,14 +1,15 @@
<script lang="ts" setup> <script lang="ts" setup>
// NOTE: Library // NOTE: Library
import { onMounted, reactive } from 'vue'; import { onMounted, reactive, watch } from 'vue';
// NOTE: Components // NOTE: Components
import SelectInput from 'src/components/shared/SelectInput.vue'; import SelectInput from 'src/components/shared/SelectInput.vue';
import StatCardComponent from 'src/components/StatCardComponent.vue'; import StatCardComponent from 'src/components/StatCardComponent.vue';
import ChartReceipt from './chart/ChartReceipt.vue'; import ChartReceipt from './chart/ChartReceipt.vue';
import ChartOpportunity from './chart/ChartOpportunity.vue';
import ChartQuotationStatus from './chart/ChartQuotationStatus.vue'; import ChartQuotationStatus from './chart/ChartQuotationStatus.vue';
import ChartSales from './chart/ChartSales.vue'; import VueDatePicker from '@vuepic/vue-datepicker';
// import ChartOpportunity from './chart/ChartOpportunity.vue';
// import ChartSales from './chart/ChartSales.vue';
// NOTE: Stores & Type // NOTE: Stores & Type
import { useNavigator } from 'src/stores/navigator'; import { useNavigator } from 'src/stores/navigator';
@ -16,6 +17,7 @@ import { useQuotationStore } from 'src/stores/quotations';
import { storeToRefs } from 'pinia'; import { storeToRefs } from 'pinia';
import { useReportStore } from 'src/stores/report'; import { useReportStore } from 'src/stores/report';
import { PaymentDataStatus } from 'src/stores/payment/types'; import { PaymentDataStatus } from 'src/stores/payment/types';
import { dateFormatJS } from 'src/utils/datetime';
// NOTE: Variable // NOTE: Variable
const navigatorStore = useNavigator(); const navigatorStore = useNavigator();
@ -24,57 +26,101 @@ const reportStore = useReportStore();
const { stats: quotationStats } = storeToRefs(quotationStore); const { stats: quotationStats } = storeToRefs(quotationStore);
const { dataReportPayment } = storeToRefs(reportStore); const { dataReportPayment } = storeToRefs(reportStore);
const endDate = new Date();
const startDate = new Date(new Date().setFullYear(endDate.getFullYear() - 1));
const state = reactive({ const state = reactive({
role: 'admin', role: 'admin',
year: 'now', date: [startDate, endDate],
}); });
// NOTE: mock // const option = reactive({
const option = reactive({ // role: [
role: [ // { label: '', value: 'admin' },
{ label: 'ผู้ดูแล', value: 'admin' }, // { label: '', value: 'user' },
{ label: 'ผู้ใช้', value: 'user' }, // ],
], // });
year: [
{ label: 'ปีนี้', value: 'now' }, async function fetchData() {
{ label: '2024', value: '2024' }, const ret = await quotationStore.getQuotationStats({
], startDate: state.date[0],
}); endDate: state.date[1],
});
if (ret) {
quotationStats.value = {
issued: 0,
accepted: 0,
expired: 0,
paymentInProcess: 0,
paymentSuccess: 0,
processComplete: 0,
canceled: 0,
};
quotationStats.value = Object.assign(quotationStats.value, ret);
}
const retPayment = await reportStore.getReportPayment({
startDate: state.date[0],
endDate: state.date[1],
});
if (retPayment) {
dataReportPayment.value = retPayment;
}
}
onMounted(async () => { onMounted(async () => {
navigatorStore.current.title = 'dashboard.title'; navigatorStore.current.title = 'dashboard.title';
navigatorStore.current.path = [{ text: '' }]; navigatorStore.current.path = [{ text: '' }];
const ret = await quotationStore.getQuotationStats(); await fetchData();
if (ret) {
quotationStats.value = Object.assign(quotationStats.value, ret);
console.log(quotationStats.value);
}
const retPayment = await reportStore.getReportPayment();
if (retPayment) {
dataReportPayment.value = retPayment;
}
}); });
watch(
() => state.date,
async () => {
await fetchData();
},
);
</script> </script>
<template> <template>
<div class="column full-height no-wrap"> <div class="column full-height no-wrap">
<header class="row q-gutter-sm"> <header class="row q-gutter-sm">
<SelectInput <!-- <SelectInput
class="col-md-3 col" class="col-md-3 col"
:option="option.role" :option="option.role"
v-model="state.role" v-model="state.role"
></SelectInput> for="selecte-role"
<SelectInput ></SelectInput> -->
class="col-md-2 col" <VueDatePicker
:option="option.year" utc
v-model="state.year" range
teleport
auto-apply
for="select-date-range"
class="col-md-4 col"
v-model="state.date"
:dark="$q.dark.isActive"
:locale="$i18n.locale === 'tha' ? 'th' : 'en'"
> >
<template #prepend> <template #trigger>
<q-icon name="mdi-calendar-outline app-text-muted" /> <q-input
placeholder="DD/MM/YYYY"
hide-bottom-space
dense
outlined
for="select-date-range"
:model-value="
dateFormatJS({ date: state.date[0] }) +
' - ' +
dateFormatJS({ date: state.date[1] })
"
>
<template #prepend>
<q-icon name="mdi-calendar-outline" class="app-text-muted" />
</template>
</q-input>
</template> </template>
</SelectInput> </VueDatePicker>
<div class="col-12 scroll"> <div class="col-12 scroll">
<div style="display: inline-block"> <div style="display: inline-block">

View file

@ -105,3 +105,9 @@ const chartOptions = computed(() => ({
></VueApexCharts> ></VueApexCharts>
</div> </div>
</template> </template>
<style scoped>
:deep(.apexcharts-menu.apexcharts-menu-open) {
background: var(--surface-1);
}
</style>

View file

@ -23,21 +23,6 @@ const prop = withDefaults(
}, },
); );
const chartOptions = computed(() => { const chartOptions = computed(() => {
const thaiMonths = [
'ม.ค.',
'ก.พ.',
'มี.ค.',
'เม.ย.',
'พ.ค.',
'มิ.ย.',
'ก.ค.',
'ส.ค.',
'ก.ย.',
'ต.ค.',
'พ.ย.',
'ธ.ค.',
];
return { return {
colors: ['#035aa1', '#ae3ec9', '#ffa94d', '#e64980'], colors: ['#035aa1', '#ae3ec9', '#ffa94d', '#e64980'],
chart: { chart: {
@ -47,6 +32,9 @@ const chartOptions = computed(() => {
enabled: false, enabled: false,
}, },
}, },
tooltip: {
theme: 'dark',
},
legend: { legend: {
position: 'right', position: 'right',
}, },
@ -109,3 +97,9 @@ const detail = [
</div> </div>
</div> </div>
</template> </template>
<style scoped>
:deep(.apexcharts-menu.apexcharts-menu-open) {
background: var(--surface-1);
}
</style>

View file

@ -135,6 +135,11 @@ const routes: RouteRecordRaw[] = [
name: 'dashBoard', name: 'dashBoard',
component: () => import('pages/15_dash-board/MainPage.vue'), component: () => import('pages/15_dash-board/MainPage.vue'),
}, },
{
path: '/notification',
name: 'Notification',
component: () => import('pages/00_notification/MainPage.vue'),
},
], ],
}, },
@ -208,7 +213,6 @@ const routes: RouteRecordRaw[] = [
name: 'DebitNoteView', name: 'DebitNoteView',
component: () => import('pages/12_debit-note/FormPage.vue'), component: () => import('pages/12_debit-note/FormPage.vue'),
}, },
{ {
path: '/debit-note/document-view', path: '/debit-note/document-view',
name: 'DebitNoteDocumentView', name: 'DebitNoteDocumentView',

View file

@ -4,7 +4,13 @@ import { api } from 'src/boot/axios';
import { PaginationResult } from 'src/types'; import { PaginationResult } from 'src/types';
import { createDataRefBase } from '../utils'; import { createDataRefBase } from '../utils';
export type Notification = {}; export type Notification = {
id: string;
title: string;
detail: string;
read?: boolean;
createdAt: string;
};
export const useNotification = defineStore('noti-store', () => { export const useNotification = defineStore('noti-store', () => {
const state = createDataRefBase<Notification>(); const state = createDataRefBase<Notification>();
@ -28,9 +34,12 @@ export const useNotification = defineStore('noti-store', () => {
return res.data; return res.data;
} }
async function updateNotification(paymentId: string, payload: Notification) { async function updateNotification(
notificationId: string,
payload: Notification,
) {
const res = await api.put<Notification & { id: string }>( const res = await api.put<Notification & { id: string }>(
`/notification/${paymentId}`, `/notification/${notificationId}`,
payload, payload,
); );
if (res.status >= 400) return null; if (res.status >= 400) return null;
@ -45,6 +54,26 @@ export const useNotification = defineStore('noti-store', () => {
return res.data; return res.data;
} }
async function deleteMultiNotification(notificationId: string[]) {
const res = await api.delete<Notification & { id: string }>(
'/notification',
{
data: notificationId,
},
);
if (res.status >= 400) return null;
return true;
}
async function markReadNotification(notificationId: string[]) {
const res = await api.post<Notification & { id: string }>(
'/notification/mark-read',
{ id: notificationId },
);
if (res.status >= 400) return null;
return true;
}
return { return {
...state, ...state,
@ -52,5 +81,7 @@ export const useNotification = defineStore('noti-store', () => {
getNotificationList, getNotificationList,
updateNotification, updateNotification,
deleteNotification, deleteNotification,
deleteMultiNotification,
markReadNotification,
}; };
}); });

View file

@ -32,8 +32,11 @@ export const useQuotationStore = defineStore('quotation-store', () => {
canceled: 0, canceled: 0,
}); });
async function getQuotationStats() { async function getQuotationStats(params?: {
const res = await api.get<QuotationStats>('/quotation/stats'); startDate: string | Date;
endDate: string | Date;
}) {
const res = await api.get<QuotationStats>('/quotation/stats', { params });
if (res.status < 400) { if (res.status < 400) {
return res.data; return res.data;
} }

View file

@ -414,6 +414,7 @@ export type QuotationPaymentData = {
remark: string; remark: string;
date: string; date: string;
id: string; id: string;
code: string;
}; };
export type Details = { export type Details = {

View file

@ -1,6 +1,5 @@
import { ref } from 'vue'; import { ref } from 'vue';
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { Pagination, Status } from '../types';
import { api } from 'src/boot/axios'; import { api } from 'src/boot/axios';
import { import {
Report, Report,
@ -8,10 +7,35 @@ import {
ReportProduct, ReportProduct,
ReportQuotation, ReportQuotation,
ReportSale, ReportSale,
ReportProfit,
} from './types'; } from './types';
import { baseUrl } from '../utils';
import { getToken } from 'src/services/keycloak';
const ENDPOINT = 'report'; const ENDPOINT = 'report';
async function _download(url: string, filename?: string) {
const res = await fetch(url, {
headers: { ['Authorization']: 'Bearer ' + (await getToken()) },
});
const text = await res.json();
const blob = new Blob([text], { type: 'text/csv' });
if (res.ok && blob) {
const a = document.createElement('a');
a.download = (filename || 'report') + '.csv';
a.href = window.URL.createObjectURL(blob);
a.click();
a.remove();
}
}
export async function downloadReportQuotation() {
await _download(
baseUrl + '/' + ENDPOINT + '/quotation/download',
'quotation-report',
);
}
export async function getReportQuotation() { export async function getReportQuotation() {
const res = await api.get<ReportQuotation[]>(`/${ENDPOINT}/quotation`); const res = await api.get<ReportQuotation[]>(`/${ENDPOINT}/quotation`);
if (res.status < 400) { if (res.status < 400) {
@ -21,6 +45,13 @@ export async function getReportQuotation() {
return null; return null;
} }
export async function downloadReportInvoice() {
await _download(
baseUrl + '/' + ENDPOINT + '/invoice/download',
'invoice-report',
);
}
export async function getReportInvoice() { export async function getReportInvoice() {
const res = await api.get<Report[]>(`/${ENDPOINT}/invoice`); const res = await api.get<Report[]>(`/${ENDPOINT}/invoice`);
if (res.status < 400) { if (res.status < 400) {
@ -29,6 +60,13 @@ export async function getReportInvoice() {
return null; return null;
} }
export async function downloadReportReceipt() {
await _download(
baseUrl + '/' + ENDPOINT + '/receipt/download',
'receipt-report',
);
}
export async function getReportReceipt() { export async function getReportReceipt() {
const res = await api.get<Report[]>(`/${ENDPOINT}/receipt`); const res = await api.get<Report[]>(`/${ENDPOINT}/receipt`);
if (res.status < 400) { if (res.status < 400) {
@ -37,6 +75,15 @@ export async function getReportReceipt() {
return null; return null;
} }
export async function downloadReportSale(
category: 'by-product-group' | 'by-sale' | 'by-customer',
) {
await _download(
baseUrl + '/' + ENDPOINT + '/sale/' + category + '/download',
'sale-' + category + '-report',
);
}
export async function getReportSale() { export async function getReportSale() {
const res = await api.get<ReportSale>(`/${ENDPOINT}/sale`); const res = await api.get<ReportSale>(`/${ENDPOINT}/sale`);
@ -46,16 +93,53 @@ export async function getReportSale() {
return null; return null;
} }
export async function downloadReportProduct() {
await _download(
baseUrl + '/' + ENDPOINT + '/receipt/download',
'product-report',
);
}
export async function getReportProduct() { export async function getReportProduct() {
const res = await api.get<ReportProduct[]>(`/${ENDPOINT}/Product`); const res = await api.get<ReportProduct[]>(`/${ENDPOINT}/product`);
if (res.status < 400) { if (res.status < 400) {
return res.data; return res.data;
} }
return null; return null;
} }
export async function getReportPayment() { export async function getReportPayment(params?: {
const res = await api.get<ReportPayment[]>(`/${ENDPOINT}/payment`); startDate: string | Date;
endDate: string | Date;
}) {
const res = await api.get<ReportPayment[]>(`/${ENDPOINT}/payment`, {
params,
});
if (res.status < 400) {
return res.data;
}
return null;
}
export async function getReportProfit(params?: {
years?: number;
startDate?: string | Date;
endDate?: string | Date;
}) {
let opts = params || {};
if (params?.years) {
const currentYear = new Date().getFullYear();
opts.startDate = new Date(currentYear - params.years, 0, 1);
opts.endDate = new Date(currentYear, 11, 31);
}
const res = await api.get<ReportProfit>(`/${ENDPOINT}/profit`, {
params: {
startDate: opts.startDate,
endDate: opts.endDate,
},
});
if (res.status < 400) { if (res.status < 400) {
return res.data; return res.data;
} }
@ -69,6 +153,7 @@ export const useReportStore = defineStore('report-store', () => {
const dataReportSale = ref<ReportSale>(); const dataReportSale = ref<ReportSale>();
const dataReportProduct = ref<ReportProduct[]>([]); const dataReportProduct = ref<ReportProduct[]>([]);
const dataReportPayment = ref<ReportPayment[]>([]); const dataReportPayment = ref<ReportPayment[]>([]);
const dataReportProfit = ref<ReportProfit>();
return { return {
dataReportQuotation, dataReportQuotation,
@ -77,12 +162,19 @@ export const useReportStore = defineStore('report-store', () => {
dataReportSale, dataReportSale,
dataReportProduct, dataReportProduct,
dataReportPayment, dataReportPayment,
dataReportProfit,
downloadReportQuotation,
getReportQuotation, getReportQuotation,
downloadReportInvoice,
getReportInvoice, getReportInvoice,
downloadReportReceipt,
getReportReceipt, getReportReceipt,
downloadReportSale,
getReportSale, getReportSale,
downloadReportProduct,
getReportProduct, getReportProduct,
getReportPayment, getReportPayment,
getReportProfit,
}; };
}); });

View file

@ -5,8 +5,10 @@ import { CustomerBranch } from '../customer';
import { PaymentDataStatus } from '../payment/types'; import { PaymentDataStatus } from '../payment/types';
export type ReportQuotation = { export type ReportQuotation = {
customerBranch: CustomerBranch;
updatedAt: Date | null; updatedAt: Date | null;
createdAt: Date | null; createdAt: Date | null;
amount: number;
status: QuotationStatus; status: QuotationStatus;
code: string; code: string;
}; };
@ -20,7 +22,9 @@ export enum Status {
// use with Invoice and Receipt // use with Invoice and Receipt
export type Report = { export type Report = {
customerBranch: CustomerBranch;
createdAt: Date | null; createdAt: Date | null;
amount: number;
status: Status; status: Status;
code: string; code: string;
}; };
@ -46,3 +50,16 @@ export type ReportSale = {
bySale: (User & { _count: number })[]; bySale: (User & { _count: number })[];
byProductGroup: (Omit<ProductGroup, '_count'> & { _count: number })[]; byProductGroup: (Omit<ProductGroup, '_count'> & { _count: number })[];
}; };
export type ReportProfit = {
dataset: {
netProfit: number;
expenses: number;
income: number;
year: number;
month: number;
}[];
netProfit: number;
expenses: number;
income: number;
};

View file

@ -532,7 +532,7 @@ export function createDataRefBase<T>(
defaultPageSize = 30, defaultPageSize = 30,
) { ) {
return { return {
data: ref<T[]>(), data: ref<T[]>([]),
page: ref<number>(defaultPage), page: ref<number>(defaultPage),
pageMax: ref<number>(defaultPageMax), pageMax: ref<number>(defaultPageMax),
pageSize: ref<number>(defaultPageSize), pageSize: ref<number>(defaultPageSize),