Merge branch 'develop'

This commit is contained in:
Methapon2001 2025-03-05 17:00:19 +07:00
commit 2f17e8a35d
14 changed files with 686 additions and 17 deletions

View file

@ -1,7 +1,8 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue/dist/iconify.js';
import { formatNumberDecimal } from 'src/stores/utils';
defineEmits<{ (e: 'labelClick', value: string, index: number): void }>();
defineEmits<{ (e: 'labelClick', value: string, index: number | null): void }>();
withDefaults(
defineProps<{
@ -38,7 +39,7 @@ withDefaults(
v-if="typeof value === 'string'"
@click="$emit('labelClick', value, null)"
>
{{ value }}
{{ formatNumberDecimal(+value, 2) }}
<q-tooltip v-if="tooltip" :delay="500">{{ value }}</q-tooltip>
</span>
<span v-else :class="{ 'link cursor-pointer': clickable }">

View file

@ -113,6 +113,7 @@ export default {
group: 'Group',
laborIdentified: 'Labor identified',
beDue: 'Due in',
noReason: 'No reason',
due: 'Due',
overDue: 'Overdue',
status: 'Status',
@ -936,6 +937,7 @@ export default {
InProgress: 'In Progress',
Completed: 'Completed',
Canceled: 'Canceled',
CancelRequested: 'Cancel Requested',
AwaitOrder: 'Awaiting Order',
ReadyOrder: 'Ready for Order',
@ -1336,7 +1338,6 @@ export default {
Succeed: 'Completed',
},
},
report: {
report: {
title: 'Report',
@ -1373,4 +1374,36 @@ export default {
},
},
},
dashboard: {
title: 'Dashboard',
newComer: 'New Interested Party',
openQuotation: 'Open Quotation',
paidSales: 'Paid Sales',
pendingSales: 'Pending Sales',
receipt: {
title: 'Tax Invoice Summary',
caption: 'By Total Amount',
total: 'Total Amount',
paid: 'Partially & Fully Paid',
pending: 'Pending Payment',
cancel: 'Cancelled',
},
opportunity: {
title: 'Sales Opportunity Screening',
caption: 'By Sales Opportunity Status',
},
quotation: {
title: 'Quotations & Sales Orders',
caption: 'By Document Status',
waitCustomer: 'Waiting for Customer',
inProgress: 'In Progress',
complete: 'Complete',
cancel: 'Cancel',
},
sales: {
title: 'Top 5 Highest Sales',
caption: 'Based on Tax Invoices',
},
},
};

View file

@ -113,6 +113,7 @@ export default {
group: 'กลุ่ม',
laborIdentified: 'ระบุแรงงาน',
beDue: 'จะครบกำหนดในอีก',
noReason: 'ไม่มีเหตุผล',
due: 'ครบกำหนด',
overDue: 'เลยกำหนด',
status: 'สถานะ',
@ -924,6 +925,7 @@ export default {
InProgress: 'กำลังดำเนินการ',
Completed: 'เสร็จสิ้น',
Canceled: 'ยกเลิก',
CancelRequested: 'ต้องการยกเลิก',
AwaitOrder: 'รอสั่งงาน',
ReadyOrder: 'พร้อมสั่งงาน',
@ -1351,4 +1353,36 @@ export default {
count: 'จำนวนที่ขาย',
},
},
dashboard: {
title: 'Dashboard',
newComer: 'ผู้สนใจใหม่',
openQuotation: 'ใบเสนอราคาที่เปิดอยู่',
paidSales: 'ยอดขายที่ชำระแล้ว',
pendingSales: 'ยอดขายที่รอชำระ',
receipt: {
title: 'สรุปยอดใบกำกับภาษี',
caption: 'ตามจำนวนเงินรวม',
total: 'ยอดรวมทั้งหมด',
paid: 'ชำระแล้ว',
pending: 'รอชำระ',
cancel: 'ยกเลิก',
},
opportunity: {
title: 'การคัดกรองโอกาสทางการขาย',
caption: 'ตามสถานะโอกาสทางการขาย',
},
quotation: {
title: 'ใบเสนอราคาและใบสั่งขาย',
caption: 'ตามสถานะเอกสาร',
waitCustomer: 'รอลูกค้าตอบรับ',
inProgress: 'กำลังดำเนินการ',
complete: 'เสร็จสิ้น',
cancel: 'ยกเลิก',
},
sales: {
title: '5 อันดับยอดขายสูงสุด',
caption: 'ตามใบกำกับภาษี',
},
},
};

View file

@ -1,20 +1,9 @@
<script setup lang="ts">
import { ref } from 'vue';
import { baseUrl } from 'src/stores/utils';
import BadgeComponent from 'src/components/BadgeComponent.vue';
import { ProductRelation, PayCondition } from 'src/stores/quotations/types';
import { Step, RequestWorkStatus } from 'src/stores/request-list/types';
const workStatus = ref([
RequestWorkStatus.Ready,
RequestWorkStatus.Waiting,
RequestWorkStatus.InProgress,
RequestWorkStatus.Validate,
RequestWorkStatus.Ended,
RequestWorkStatus.Completed,
]);
import BadgeComponent from 'src/components/BadgeComponent.vue';
defineEmits<{
(
@ -29,6 +18,8 @@ const props = defineProps<{
code: string;
status?: Step;
imgUrl?: string;
requestCancel?: boolean;
requestCancelReason?: string;
installmentInfo?: {
total: number;
paid?: number;
@ -108,6 +99,18 @@ function changeableStatus(currentStatus?: RequestWorkStatus) {
<div class="rounded q-px-xs app-text-muted surface-3">
{{ product?.code || code }}
</div>
<BadgeComponent
v-if="requestCancel && !cancel"
:hsla-color="'--red-5-hsl'"
class="q-ml-sm"
:title="$t(`requestList.status.CancelRequested`) || '-'"
>
<template #append>
<q-tooltip>
{{ requestCancelReason || $t('general.noReason') }}
</q-tooltip>
</template>
</BadgeComponent>
</div>
<div class="q-ml-auto q-gutter-y-xs">

View file

@ -731,6 +731,8 @@ async function submitRequestAction(data: {
<!-- product -->
<template v-for="(value, index) in productsList" :key="value">
<ProductExpansion
:request-cancel="value.customerRequestCancel"
:request-cancel-reason="value.customerRequestCancelReason"
:cancel="data.requestDataStatus === RequestDataStatus.Canceled"
:readonly="
data.requestDataStatus === RequestDataStatus.Canceled ||

View file

@ -183,6 +183,24 @@ function getEmployeeName(
$t(`requestList.status.${props.row.requestDataStatus}`) || '-'
"
/>
<BadgeComponent
v-if="
props.row.customerRequestCancel &&
props.row.requestDataStatus !== RequestDataStatus.Canceled
"
:hsla-color="'--red-5-hsl'"
class="q-ml-sm"
:title="$t(`requestList.status.CancelRequested`) || '-'"
>
<template #append>
<q-tooltip>
{{
props.row.customerRequestCancelReason ||
$t('general.noReason')
}}
</q-tooltip>
</template>
</BadgeComponent>
</q-td>
<q-td class="text-right">
<q-btn

View file

@ -1,11 +1,209 @@
<script lang="ts" setup>
// NOTE: Library
import { onMounted, reactive } from 'vue';
// NOTE: Components
import SelectInput from 'src/components/shared/SelectInput.vue';
import StatCardComponent from 'src/components/StatCardComponent.vue';
import ChartReceipt from './chart/ChartReceipt.vue';
import ChartOpportunity from './chart/ChartOpportunity.vue';
import ChartQuotationStatus from './chart/ChartQuotationStatus.vue';
import ChartSales from './chart/ChartSales.vue';
// NOTE: Stores & Type
import { useNavigator } from 'src/stores/navigator';
import { useQuotationStore } from 'src/stores/quotations';
import { storeToRefs } from 'pinia';
import { useReportStore } from 'src/stores/report';
import { PaymentDataStatus } from 'src/stores/payment/types';
// NOTE: Variable
const navigatorStore = useNavigator();
const quotationStore = useQuotationStore();
const reportStore = useReportStore();
const { stats: quotationStats } = storeToRefs(quotationStore);
const { dataReportPayment } = storeToRefs(reportStore);
const state = reactive({
role: 'admin',
year: 'now',
});
// NOTE: mock
const option = reactive({
role: [
{ label: 'ผู้ดูแล', value: 'admin' },
{ label: 'ผู้ใช้', value: 'user' },
],
year: [
{ label: 'ปีนี้', value: 'now' },
{ label: '2024', value: '2024' },
],
});
onMounted(async () => {
navigatorStore.current.title = 'dashboard.title';
navigatorStore.current.path = [{ text: '' }];
const ret = await quotationStore.getQuotationStats();
if (ret) {
quotationStats.value = Object.assign(quotationStats.value, ret);
console.log(quotationStats.value);
}
const retPayment = await reportStore.getReportPayment();
if (retPayment) {
dataReportPayment.value = retPayment;
}
});
</script>
<template></template>
<template>
<div class="column full-height no-wrap">
<header class="row q-gutter-sm">
<SelectInput
class="col-md-3 col"
:option="option.role"
v-model="state.role"
></SelectInput>
<SelectInput
class="col-md-2 col"
:option="option.year"
v-model="state.year"
>
<template #prepend>
<q-icon name="mdi-calendar-outline app-text-muted" />
</template>
</SelectInput>
<div class="col-12 scroll">
<div style="display: inline-block">
<StatCardComponent
:dark="$q.dark.isActive"
text-size="10px"
:branch="[
{
icon: 'material-symbols-light:receipt-long',
count:
(quotationStats.issued || 0) +
(quotationStats.accepted || 0) +
(quotationStats.paymentInProcess || 0) +
(quotationStats.paymentSuccess || 0) +
(quotationStats.processComplete || 0) +
(quotationStats.canceled || 0),
label: $t('dashboard.openQuotation'),
color: 'cyan',
},
{
icon: 'si:wallet-detailed-line',
count:
(quotationStats.paymentSuccess || 0) +
(quotationStats.processComplete || 0),
label: $t('dashboard.paidSales'),
color: 'light-yellow',
},
{
icon: 'fluent:money-hand-24-regular',
count:
(quotationStats.issued || 0) +
(quotationStats.accepted || 0) +
(quotationStats.paymentInProcess || 0),
label: $t('dashboard.pendingSales'),
color: 'light-purple',
},
]"
/>
</div>
</div>
</header>
<section class="col q-mt-md">
<div class="full-height scroll">
<article class="row q-col-gutter-sm">
<div class="col-sm-8 col-12">
<ChartReceipt
class="full-height"
:summary="[
dataReportPayment.reduce(
(a, c) =>
a +
(c.data[PaymentDataStatus.Success] || 0) +
(c.data[PaymentDataStatus.Wait] || 0),
0,
),
dataReportPayment.reduce(
(a, c) => a + (c.data[PaymentDataStatus.Success] || 0),
0,
),
dataReportPayment.reduce(
(a, c) => a + (c.data[PaymentDataStatus.Wait] || 0),
0,
),
]"
:categories="
dataReportPayment.map((v) => v.month + ' / ' + v.year)
"
:series="[
{
name: $t('dashboard.receipt.total'),
data: dataReportPayment.map(
(v) =>
(v.data[PaymentDataStatus.Success] || 0) +
(v.data[PaymentDataStatus.Wait] || 0),
),
},
{
name: $t('dashboard.receipt.paid'),
data: dataReportPayment.map(
(v) => v.data[PaymentDataStatus.Success] || 0,
),
},
{
name: $t('dashboard.receipt.pending'),
data: dataReportPayment.map(
(v) => v.data[PaymentDataStatus.Wait] || 0,
),
},
]"
/>
</div>
<!-- <div class="col-sm-4 col-12">
<ChartOpportunity
class="full-height"
:labels="['1', '2', '3', '4', '5', '6', '7', '8']"
:series="[581, 389, 609, 581, 603, 600, 699, 347]"
/>
</div> -->
<div class="col-sm-4 col-12">
<ChartQuotationStatus
v-if="quotationStats"
class="full-height"
:labels="[
$t('dashboard.quotation.waitCustomer'),
$t('dashboard.quotation.inProgress'),
$t('dashboard.quotation.complete'),
$t('dashboard.quotation.cancel'),
]"
:series="[
{
data: [
(quotationStats.issued || 0) +
(quotationStats.accepted || 0),
(quotationStats.paymentInProcess || 0) +
(quotationStats.paymentSuccess || 0),
quotationStats.processComplete || 0,
quotationStats.canceled || 0,
],
},
]"
/>
</div>
<!-- <div class="col-sm-8 col-12">
<ChartSales class="full-height" />
</div> -->
</article>
</div>
</section>
</div>
</template>
<style scoped></style>

View file

@ -0,0 +1,58 @@
<script lang="ts" setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import VueApexCharts from 'vue3-apexcharts';
const props = withDefaults(
defineProps<{
series: number[];
labels: string[];
}>(),
{
labels: () => ['series-1', 'series-2', 'series-3', 'series-4'],
series: () => [1, 1, 1, 1],
},
);
const chartOptions = computed(() => ({
labels: props.labels,
colors: [
'#1F253B',
'#1B285C',
'#031B7D',
'#0023AE',
'#0933DA',
'#365AE9',
'#6380F8',
'#7583BB',
],
chart: {
fontFamily: 'Noto Sans Thai',
type: 'donut',
},
dataLabels: {
style: {
fontSize: '10px',
color: 'red',
},
},
legend: {
show: false,
position: 'bottom',
},
}));
</script>
<template>
<div class="surface-1 rounded bordered q-pa-md">
{{ $t('dashboard.opportunity.title') }}
<div class="text-caption app-text-muted">
{{ $t('dashboard.opportunity.caption') }}
</div>
<VueApexCharts
type="donut"
height="200"
:options="chartOptions"
:series="series"
></VueApexCharts>
</div>
</template>

View file

@ -0,0 +1,107 @@
<script lang="ts" setup>
import { computed } from 'vue';
import VueApexCharts from 'vue3-apexcharts';
const props = withDefaults(
defineProps<{
series: { data: number[] }[];
labels: string[];
}>(),
{
series: () => [
{
data: [1, 2, 3, 4],
},
],
labels: () => ['1', '2', '3', '4'],
},
);
const chartOptions = computed(() => ({
chart: {
type: 'bar',
fontFamily: 'Noto Sans Thai',
},
plotOptions: {
bar: {
barHeight: '100%',
distributed: true,
horizontal: true,
borderRadius: 6,
borderRadiusApplication: 'end',
dataLabels: {
position: 'bottom',
},
},
},
colors: ['#f59f00', '#035aa1', '#0ca678', '#c92a2a'],
dataLabels: {
enabled: false,
textAnchor: 'start',
style: {
colors: ['#fff'],
},
formatter: function (
val: string,
opt: {
w: { globals: { labels: { [x: string]: string } } };
dataPointIndex: string | number;
},
) {
return opt.w.globals.labels[opt.dataPointIndex] + ': ' + val;
},
offsetX: 0,
dropShadow: {
enabled: true,
},
},
stroke: {
width: 1,
colors: ['#fff'],
},
xaxis: {
categories: props.labels,
},
yaxis: {
labels: {
show: false,
},
},
legend: {
show: false,
},
tooltip: {
theme: 'dark',
x: {
show: false,
},
y: {
title: {
formatter: function (
val: string,
opt: {
w: { globals: { labels: { [x: string]: string } } };
dataPointIndex: string | number;
},
) {
return opt.w.globals.labels[opt.dataPointIndex] + ':';
},
},
},
},
}));
</script>
<template>
<div class="surface-1 rounded bordered q-pa-md">
{{ $t('dashboard.quotation.title') }}
<div class="text-caption app-text-muted">
{{ $t('dashboard.quotation.caption') }}
</div>
<VueApexCharts
type="bar"
height="200"
:options="chartOptions"
:series="series"
></VueApexCharts>
</div>
</template>

View file

@ -0,0 +1,111 @@
<script lang="ts" setup>
import { Icon } from '@iconify/vue/dist/iconify.js';
import VueApexCharts from 'vue3-apexcharts';
import DataDisplay from 'src/components/08_request-list/DataDisplay.vue';
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
const { locale } = useI18n({ useScope: 'global' });
const prop = withDefaults(
defineProps<{
series: { name: string; data: number[] }[];
summary: number[];
categories: string[];
}>(),
{
series: () => [
{ name: 'series-1', data: [0, 0] },
{ name: 'series-2', data: [1, 1] },
{ name: 'series-3', data: [2, 2] },
],
summary: () => [0, 0, 0, 0],
categories: () => ['Jan', 'Feb', 'Mar'],
},
);
const chartOptions = computed(() => {
const thaiMonths = [
'ม.ค.',
'ก.พ.',
'มี.ค.',
'เม.ย.',
'พ.ค.',
'มิ.ย.',
'ก.ค.',
'ส.ค.',
'ก.ย.',
'ต.ค.',
'พ.ย.',
'ธ.ค.',
];
return {
colors: ['#035aa1', '#ae3ec9', '#ffa94d', '#e64980'],
chart: {
fontFamily: 'Noto Sans Thai',
type: 'line',
zoom: {
enabled: false,
},
},
legend: {
position: 'right',
},
xaxis: {
categories: prop.categories,
},
};
});
const detail = [
{
label: 'total',
color: 'var(--brand-1)',
icon: 'hugeicons:wallet-03',
},
{
label: 'paid',
color: 'var(--purple-7)',
icon: 'material-symbols-light:credit-card-clock-outline-rounded',
},
{
label: 'pending',
color: 'var(--orange-4)',
icon: 'material-symbols-light:credit-card-outline',
},
];
</script>
<template>
<div class="surface-1 rounded bordered q-pa-md">
{{ $t('dashboard.receipt.title') }}
<div class="text-caption app-text-muted">
{{ $t('dashboard.receipt.caption') }}
</div>
<VueApexCharts
type="line"
height="150"
:options="chartOptions"
:series="series"
></VueApexCharts>
<div class="row items-center">
<span
class="col-md col-6 row no-wrap items-center"
v-for="(v, i) in detail"
:key="v.label"
>
<q-avatar
class="q-mr-sm"
size="md"
font-size="18px"
:style="`background-color: ${v.color}; color: var(--surface-1)`"
>
<Icon :icon="v.icon" />
</q-avatar>
<DataDisplay
:label="$t(`dashboard.receipt.${v.label}`)"
:value="summary[i].toString()"
/>
</span>
</div>
</div>
</template>

View file

@ -0,0 +1,75 @@
<script lang="ts" setup>
import { useI18n } from 'vue-i18n';
import VueApexCharts from 'vue3-apexcharts';
const { t } = useI18n();
const series = [
{
name: 'Net Profit',
data: [44, 55, 57, 56, 61, 58, 63, 60, 66],
},
{
name: 'Revenue',
data: [76, 85, 101, 98, 87, 105, 91, 114, 94],
},
{
name: 'Free Cash Flow',
data: [35, 41, 36, 26, 45, 48, 52, 53, 41],
},
];
const chartOptions = {
chart: {
type: 'bar',
fontFamily: 'Noto Sans Thai',
},
plotOptions: {
bar: {
horizontal: false,
columnWidth: '55%',
borderRadius: 2,
borderRadiusApplication: 'end',
},
},
dataLabels: {
enabled: false,
},
stroke: {
show: true,
width: 2,
colors: ['transparent'],
},
xaxis: {
categories: ['Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct'],
},
yaxis: {
title: {
text: '$ (thousands)',
},
},
fill: {
opacity: 1,
},
tooltip: {
y: {
formatter: function (val: string) {
return '$ ' + val + ' thousands';
},
},
},
};
</script>
<template>
<div class="surface-1 rounded bordered q-pa-md">
{{ $t('dashboard.sales.title') }}
<div class="text-caption app-text-muted">
{{ $t('dashboard.sales.caption') }}
</div>
<VueApexCharts
type="bar"
height="200"
:options="chartOptions"
:series="series"
></VueApexCharts>
</div>
</template>

View file

@ -2,7 +2,13 @@ import { ref } from 'vue';
import { defineStore } from 'pinia';
import { Pagination, Status } from '../types';
import { api } from 'src/boot/axios';
import { Report, ReportProduct, ReportQuotation, ReportSale } from './types';
import {
Report,
ReportPayment,
ReportProduct,
ReportQuotation,
ReportSale,
} from './types';
const ENDPOINT = 'report';
@ -48,12 +54,21 @@ export async function getReportProduct() {
return null;
}
export async function getReportPayment() {
const res = await api.get<ReportPayment[]>(`/${ENDPOINT}/payment`);
if (res.status < 400) {
return res.data;
}
return null;
}
export const useReportStore = defineStore('report-store', () => {
const dataReportQuotation = ref<ReportQuotation[]>([]);
const dataReportInvoice = ref<Report[]>([]);
const dataReportReceipt = ref<Report[]>([]);
const dataReportSale = ref<ReportSale>();
const dataReportProduct = ref<ReportProduct[]>([]);
const dataReportPayment = ref<ReportPayment[]>([]);
return {
dataReportQuotation,
@ -61,11 +76,13 @@ export const useReportStore = defineStore('report-store', () => {
dataReportReceipt,
dataReportSale,
dataReportProduct,
dataReportPayment,
getReportQuotation,
getReportInvoice,
getReportReceipt,
getReportSale,
getReportProduct,
getReportPayment,
};
});

View file

@ -2,6 +2,7 @@ import { QuotationStatus } from 'src/stores/quotations/types';
import { ProductGroup } from '../product-service/types';
import { User } from '../user';
import { CustomerBranch } from '../customer';
import { PaymentDataStatus } from '../payment/types';
export type ReportQuotation = {
updatedAt: Date | null;
@ -34,6 +35,12 @@ export type ReportProduct = {
code: string;
};
export type ReportPayment = {
year: string;
month: string;
data: Partial<Record<PaymentDataStatus, number>>;
};
export type ReportSale = {
byCustomer: (Omit<CustomerBranch, '_count'> & { _count: number })[];
bySale: (User & { _count: number })[];

View file

@ -9,6 +9,9 @@ export type RequestData = {
createdAt: string;
updatedAt: string;
customerRequestCancel?: boolean;
customerRequestCancelReason?: string;
quotation: QuotationFull & {
debitNoteQuotationId: string;
isDebitNote: boolean;
@ -60,6 +63,8 @@ export type RequestWork = {
attributes?: Attributes;
creditNoteId?: string;
processByUserId?: string;
customerRequestCancel?: boolean;
customerRequestCancelReason?: string;
};
export type RowDocument = {