Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2a7e756ad | ||
|
|
05d96fd550 | ||
|
|
6d64c2931d | ||
|
|
39921b548e | ||
|
|
7cb2b957a7 | ||
|
|
966b066358 | ||
|
|
de87412bb1 | ||
|
|
497b065b5c | ||
|
|
f7089d5ca7 | ||
|
|
60b21fd94a |
Generated
+30
@@ -11,6 +11,7 @@
|
|||||||
"@fontsource/vazirmatn": "^5.3.0",
|
"@fontsource/vazirmatn": "^5.3.0",
|
||||||
"@primevue/themes": "4.0.7",
|
"@primevue/themes": "4.0.7",
|
||||||
"axios": "^1.19.0",
|
"axios": "^1.19.0",
|
||||||
|
"chart.js": "^4.5.1",
|
||||||
"filepond": "^4.32.12",
|
"filepond": "^4.32.12",
|
||||||
"filepond-plugin-file-validate-type": "^1.2.9",
|
"filepond-plugin-file-validate-type": "^1.2.9",
|
||||||
"filepond-plugin-image-preview": "^4.6.12",
|
"filepond-plugin-image-preview": "^4.6.12",
|
||||||
@@ -24,6 +25,7 @@
|
|||||||
"sass": "^1.102.0",
|
"sass": "^1.102.0",
|
||||||
"vee-validate": "^4.15.1",
|
"vee-validate": "^4.15.1",
|
||||||
"vue": "^3.5.40",
|
"vue": "^3.5.40",
|
||||||
|
"vue-chartjs": "^5.3.4",
|
||||||
"vue-filepond": "^8.0.0",
|
"vue-filepond": "^8.0.0",
|
||||||
"vue-i18n": "^11.4.8",
|
"vue-i18n": "^11.4.8",
|
||||||
"vue-router": "^5.2.0",
|
"vue-router": "^5.2.0",
|
||||||
@@ -280,6 +282,12 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@kurkle/color": {
|
||||||
|
"version": "0.3.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
|
||||||
|
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@oxc-project/types": {
|
"node_modules/@oxc-project/types": {
|
||||||
"version": "0.143.0",
|
"version": "0.143.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.143.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.143.0.tgz",
|
||||||
@@ -1277,6 +1285,18 @@
|
|||||||
"integrity": "sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==",
|
"integrity": "sha512-rddelWYNPRrXq6PtNEN2S3f6t9ILzvqaN5pVgi4kqt9jHQaXIial9PznB5iSPVlQSLNaaH22ItWz3EJtQ10+OA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/chart.js": {
|
||||||
|
"version": "4.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
|
||||||
|
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@kurkle/color": "^0.3.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"pnpm": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/chokidar": {
|
"node_modules/chokidar": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
|
||||||
@@ -3265,6 +3285,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/vue-chartjs": {
|
||||||
|
"version": "5.3.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/vue-chartjs/-/vue-chartjs-5.3.4.tgz",
|
||||||
|
"integrity": "sha512-x3Fqob8RQvrTdssfi9ecsCzEkFOd8JPmNwSkSQzdfKj/uBsRJs/Y88cZcZIEcPsTVfMGwMo4MOoihoDG2DoE/g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"chart.js": "^4.1.1",
|
||||||
|
"vue": "^3.0.0-0 || ^2.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vue-filepond": {
|
"node_modules/vue-filepond": {
|
||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/vue-filepond/-/vue-filepond-8.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/vue-filepond/-/vue-filepond-8.0.0.tgz",
|
||||||
|
|||||||
+3
-1
@@ -7,12 +7,13 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test": "node --test src/utils/uploadResponse.test.js src/utils/classSchedule.test.js src/utils/paymentAmount.test.js src/utils/professorShare.test.js"
|
"test": "node --test src/composables/usePersianDate.test.js src/utils/uploadResponse.test.js src/utils/classSchedule.test.js src/utils/paymentAmount.test.js src/utils/professorShare.test.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource/vazirmatn": "^5.3.0",
|
"@fontsource/vazirmatn": "^5.3.0",
|
||||||
"@primevue/themes": "4.0.7",
|
"@primevue/themes": "4.0.7",
|
||||||
"axios": "^1.19.0",
|
"axios": "^1.19.0",
|
||||||
|
"chart.js": "^4.5.1",
|
||||||
"filepond": "^4.32.12",
|
"filepond": "^4.32.12",
|
||||||
"filepond-plugin-file-validate-type": "^1.2.9",
|
"filepond-plugin-file-validate-type": "^1.2.9",
|
||||||
"filepond-plugin-image-preview": "^4.6.12",
|
"filepond-plugin-image-preview": "^4.6.12",
|
||||||
@@ -26,6 +27,7 @@
|
|||||||
"sass": "^1.102.0",
|
"sass": "^1.102.0",
|
||||||
"vee-validate": "^4.15.1",
|
"vee-validate": "^4.15.1",
|
||||||
"vue": "^3.5.40",
|
"vue": "^3.5.40",
|
||||||
|
"vue-chartjs": "^5.3.4",
|
||||||
"vue-filepond": "^8.0.0",
|
"vue-filepond": "^8.0.0",
|
||||||
"vue-i18n": "^11.4.8",
|
"vue-i18n": "^11.4.8",
|
||||||
"vue-router": "^5.2.0",
|
"vue-router": "^5.2.0",
|
||||||
|
|||||||
+3
-1
@@ -12,5 +12,7 @@ export const classApi = {
|
|||||||
registerUsers: (classId, userIds, notify) =>
|
registerUsers: (classId, userIds, notify) =>
|
||||||
axiosInstance.post(`/classes/admin/${classId}/register-users`, { userIds, notify }),
|
axiosInstance.post(`/classes/admin/${classId}/register-users`, { userIds, notify }),
|
||||||
removeUser: (classId, userId) =>
|
removeUser: (classId, userId) =>
|
||||||
axiosInstance.delete(`/classes/admin/${classId}/students/${userId}`)
|
axiosInstance.delete(`/classes/admin/${classId}/students/${userId}`),
|
||||||
|
sendPlanToProfessor: (classId) =>
|
||||||
|
axiosInstance.post(`/classes/admin/${classId}/send-plan-professor`)
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
// /src/api/employeeTimingApi.js
|
||||||
|
import axiosInstance from './axiosInstance';
|
||||||
|
|
||||||
|
export const employeeTimingApi = {
|
||||||
|
getAll: (params) => axiosInstance.get('/employee-timings/admin/get-all', { params }),
|
||||||
|
getSummary: (params) => axiosInstance.get('/employee-timings/admin/summary', { params }),
|
||||||
|
getOne: (id) => axiosInstance.get(`/employee-timings/admin/get-one/${id}`),
|
||||||
|
create: (data) => axiosInstance.post('/employee-timings/admin/create', data),
|
||||||
|
update: (id, data) => axiosInstance.put(`/employee-timings/admin/update/${id}`, data),
|
||||||
|
delete: (id) => axiosInstance.delete(`/employee-timings/admin/delete/${id}`)
|
||||||
|
};
|
||||||
@@ -4,5 +4,6 @@ import axiosInstance from './axiosInstance';
|
|||||||
export const financialReportApi = {
|
export const financialReportApi = {
|
||||||
getClassReport: (classId) => axiosInstance.get(`/financial-reports/admin/classes/${classId}`),
|
getClassReport: (classId) => axiosInstance.get(`/financial-reports/admin/classes/${classId}`),
|
||||||
getSessionReport: (sessionId) => axiosInstance.get(`/financial-reports/admin/sessions/${sessionId}`),
|
getSessionReport: (sessionId) => axiosInstance.get(`/financial-reports/admin/sessions/${sessionId}`),
|
||||||
getRangeReport: (params) => axiosInstance.get('/financial-reports/admin/range', { params })
|
getRangeReport: (params) => axiosInstance.get('/financial-reports/admin/range', { params }),
|
||||||
|
getAnalytics: (params) => axiosInstance.get('/financial-reports/admin/analytics', { params })
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,9 +6,13 @@ export const paymentApi = {
|
|||||||
search: (params) => axiosInstance.get('/payments/admin/search', { params }),
|
search: (params) => axiosInstance.get('/payments/admin/search', { params }),
|
||||||
getOne: (id) => axiosInstance.get(`/payments/admin/get-one/${id}`),
|
getOne: (id) => axiosInstance.get(`/payments/admin/get-one/${id}`),
|
||||||
create: (data) => axiosInstance.post('/payments/admin/create', data),
|
create: (data) => axiosInstance.post('/payments/admin/create', data),
|
||||||
|
createBulkClass: (data) => axiosInstance.post('/payments/admin/bulk-class', data),
|
||||||
|
checkDuplicate: (params) => axiosInstance.get('/payments/admin/check-duplicate', { params }),
|
||||||
update: (id, data) => axiosInstance.put(`/payments/admin/update/${id}`, data),
|
update: (id, data) => axiosInstance.put(`/payments/admin/update/${id}`, data),
|
||||||
delete: (id) => axiosInstance.delete(`/payments/admin/delete/${id}`),
|
delete: (id) => axiosInstance.delete(`/payments/admin/delete/${id}`),
|
||||||
recordTransaction: (paymentId, data) => axiosInstance.post(`/payments/admin/transactions/${paymentId}`, data),
|
recordTransaction: (paymentId, data) => axiosInstance.post(`/payments/admin/transactions/${paymentId}`, data),
|
||||||
updateTransaction: (transactionId, data) => axiosInstance.put(`/payments/admin/transactions/${transactionId}`, data),
|
updateTransaction: (transactionId, data) => axiosInstance.put(`/payments/admin/transactions/${transactionId}`, data),
|
||||||
cancelTransaction: (transactionId) => axiosInstance.post(`/payments/admin/transactions/${transactionId}/cancel`)
|
cancelTransaction: (transactionId) => axiosInstance.post(`/payments/admin/transactions/${transactionId}/cancel`),
|
||||||
|
revertTransaction: (transactionId) => axiosInstance.post(`/payments/admin/transactions/${transactionId}/revert`),
|
||||||
|
deleteTransaction: (transactionId) => axiosInstance.delete(`/payments/admin/transactions/${transactionId}`)
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export const professorApi = {
|
|||||||
search: (params) => axiosInstance.get('/professors/admin/search', { params }),
|
search: (params) => axiosInstance.get('/professors/admin/search', { params }),
|
||||||
getOne: (id) => axiosInstance.get(`/professors/admin/get-one/${id}`),
|
getOne: (id) => axiosInstance.get(`/professors/admin/get-one/${id}`),
|
||||||
create: (data) => axiosInstance.post('/professors/admin/create', data),
|
create: (data) => axiosInstance.post('/professors/admin/create', data),
|
||||||
|
createFromUser: (data) => axiosInstance.post('/professors/admin/create-from-user', data),
|
||||||
update: (id, data) => axiosInstance.put(`/professors/admin/update/${id}`, data),
|
update: (id, data) => axiosInstance.put(`/professors/admin/update/${id}`, data),
|
||||||
delete: (id) => axiosInstance.delete(`/professors/admin/delete/${id}`)
|
delete: (id) => axiosInstance.delete(`/professors/admin/delete/${id}`)
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ export const userApi = {
|
|||||||
getAll: (params) => axiosInstance.get('/users/admin/get-all', { params }),
|
getAll: (params) => axiosInstance.get('/users/admin/get-all', { params }),
|
||||||
search: (params) => axiosInstance.get('/users/admin/search', { params }),
|
search: (params) => axiosInstance.get('/users/admin/search', { params }),
|
||||||
getOne: (id) => axiosInstance.get(`/users/admin/get-one/${id}`),
|
getOne: (id) => axiosInstance.get(`/users/admin/get-one/${id}`),
|
||||||
|
getFullProfile: (id) => axiosInstance.get(`/users/admin/${id}/full-profile`),
|
||||||
create: (data) => axiosInstance.post('/users/admin/create', data),
|
create: (data) => axiosInstance.post('/users/admin/create', data),
|
||||||
update: (id, data) => axiosInstance.put(`/users/admin/update/${id}`, data),
|
update: (id, data) => axiosInstance.put(`/users/admin/update/${id}`, data),
|
||||||
|
promoteToProfessor: (id, data) => axiosInstance.post(`/users/admin/${id}/promote-to-professor`, data),
|
||||||
resetPasswordAndSms: (id) => axiosInstance.post(
|
resetPasswordAndSms: (id) => axiosInstance.post(
|
||||||
`/users/admin/${id}/reset-password-sms`,
|
`/users/admin/${id}/reset-password-sms`,
|
||||||
{},
|
{},
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
// /src/api/waitlistApi.js
|
||||||
|
import axiosInstance from './axiosInstance';
|
||||||
|
|
||||||
|
export const waitlistApi = {
|
||||||
|
getAll: (params) => axiosInstance.get('/waitlist/admin/get-all', { params }),
|
||||||
|
getStats: () => axiosInstance.get('/waitlist/admin/stats'),
|
||||||
|
getOne: (id) => axiosInstance.get(`/waitlist/admin/get-one/${id}`),
|
||||||
|
create: (data) => axiosInstance.post('/waitlist/admin/create', data),
|
||||||
|
update: (id, data) => axiosInstance.put(`/waitlist/admin/update/${id}`, data),
|
||||||
|
assignClass: (id, data) => axiosInstance.post(`/waitlist/admin/${id}/assign-class`, data),
|
||||||
|
revert: (id, data) => axiosInstance.post(`/waitlist/admin/${id}/revert`, data),
|
||||||
|
cancel: (id, data) => axiosInstance.post(`/waitlist/admin/${id}/cancel`, data),
|
||||||
|
delete: (id) => axiosInstance.delete(`/waitlist/admin/delete/${id}`)
|
||||||
|
};
|
||||||
@@ -119,18 +119,40 @@ a {
|
|||||||
|
|
||||||
.vpd-controls {
|
.vpd-controls {
|
||||||
color: var(--text-primary) !important;
|
color: var(--text-primary) !important;
|
||||||
display: flex;
|
display: flex !important;
|
||||||
align-items: center;
|
align-items: center !important;
|
||||||
justify-content: space-between;
|
justify-content: space-between !important;
|
||||||
|
direction: rtl !important;
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
|
|
||||||
|
.vpd-prev {
|
||||||
|
order: 1 !important;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
transform: rotate(180deg) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.vpd-month-label {
|
||||||
|
order: 2 !important;
|
||||||
|
flex: 1 !important;
|
||||||
|
text-align: center !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vpd-next {
|
||||||
|
order: 3 !important;
|
||||||
|
|
||||||
|
svg {
|
||||||
|
transform: rotate(0deg) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.vpd-next,
|
.vpd-next,
|
||||||
.vpd-prev,
|
.vpd-prev,
|
||||||
button {
|
button {
|
||||||
display: inline-flex !important;
|
display: inline-flex !important;
|
||||||
align-items: center !important;
|
align-items: center !important;
|
||||||
justify-content: center !important;
|
justify-content: center !important;
|
||||||
transform: none !important;
|
|
||||||
width: 32px !important;
|
width: 32px !important;
|
||||||
height: 32px !important;
|
height: 32px !important;
|
||||||
border-radius: 8px !important;
|
border-radius: 8px !important;
|
||||||
@@ -144,7 +166,6 @@ a {
|
|||||||
width: 14px !important;
|
width: 14px !important;
|
||||||
height: 14px !important;
|
height: 14px !important;
|
||||||
fill: var(--text-primary) !important;
|
fill: var(--text-primary) !important;
|
||||||
transform: none !important;
|
|
||||||
|
|
||||||
path {
|
path {
|
||||||
fill: var(--text-primary) !important;
|
fill: var(--text-primary) !important;
|
||||||
|
|||||||
@@ -150,6 +150,122 @@ html[dir="rtl"] {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* PrimeVue InputGroup RTL Fixes */
|
||||||
|
.p-inputgroup {
|
||||||
|
direction: rtl;
|
||||||
|
|
||||||
|
// Reset default border radius on all direct elements and child inputs
|
||||||
|
> .p-component,
|
||||||
|
> .p-inputgroupaddon,
|
||||||
|
> .p-inputwrapper,
|
||||||
|
> .p-inputwrapper > .p-inputtext,
|
||||||
|
> .p-inputwrapper > .p-inputnumber-input,
|
||||||
|
> .p-inputnumber,
|
||||||
|
> .p-inputnumber .p-inputnumber-input,
|
||||||
|
> .p-inputnumber .p-inputtext,
|
||||||
|
> .p-floatlabel > .p-component,
|
||||||
|
> .p-floatlabel input,
|
||||||
|
> .p-inputtext,
|
||||||
|
> .p-button,
|
||||||
|
> button,
|
||||||
|
> input {
|
||||||
|
border-radius: 0 !important;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default addon borders
|
||||||
|
.p-inputgroupaddon {
|
||||||
|
border-top: 1px solid var(--p-inputgroup-addon-border-color, var(--border-color)) !important;
|
||||||
|
border-bottom: 1px solid var(--p-inputgroup-addon-border-color, var(--border-color)) !important;
|
||||||
|
border-left: 1px solid var(--p-inputgroup-addon-border-color, var(--border-color)) !important;
|
||||||
|
border-right: 1px solid var(--p-inputgroup-addon-border-color, var(--border-color)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
// First child in DOM is visually on the RIGHT in RTL
|
||||||
|
> .p-inputgroupaddon:first-child,
|
||||||
|
> .p-button:first-child,
|
||||||
|
> button:first-child,
|
||||||
|
> input:first-child,
|
||||||
|
> .p-inputtext:first-child,
|
||||||
|
> .p-component:first-child,
|
||||||
|
> .p-inputwrapper:first-child,
|
||||||
|
> .p-inputwrapper:first-child > .p-inputtext,
|
||||||
|
> .p-inputwrapper:first-child > .p-inputnumber-input,
|
||||||
|
> .p-inputnumber:first-child,
|
||||||
|
> .p-inputnumber:first-child .p-inputnumber-input,
|
||||||
|
> .p-inputnumber:first-child .p-inputtext,
|
||||||
|
> .p-floatlabel:first-child > .p-component,
|
||||||
|
> .p-floatlabel:first-child input {
|
||||||
|
border-top-right-radius: var(--p-inputgroup-addon-border-radius, 6px) !important;
|
||||||
|
border-bottom-right-radius: var(--p-inputgroup-addon-border-radius, 6px) !important;
|
||||||
|
border-top-left-radius: 0 !important;
|
||||||
|
border-bottom-left-radius: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
> .p-inputgroupaddon:first-child {
|
||||||
|
border-left: 0 none !important;
|
||||||
|
border-right: 1px solid var(--p-inputgroup-addon-border-color, var(--border-color)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last child in DOM is visually on the LEFT in RTL
|
||||||
|
> .p-inputgroupaddon:last-child,
|
||||||
|
> .p-button:last-child,
|
||||||
|
> button:last-child,
|
||||||
|
> input:last-child,
|
||||||
|
> .p-inputtext:last-child,
|
||||||
|
> .p-component:last-child,
|
||||||
|
> .p-inputwrapper:last-child,
|
||||||
|
> .p-inputwrapper:last-child > .p-inputtext,
|
||||||
|
> .p-inputwrapper:last-child > .p-inputnumber-input,
|
||||||
|
> .p-inputnumber:last-child,
|
||||||
|
> .p-inputnumber:last-child .p-inputnumber-input,
|
||||||
|
> .p-inputnumber:last-child .p-inputtext,
|
||||||
|
> .p-floatlabel:last-child > .p-component,
|
||||||
|
> .p-floatlabel:last-child input {
|
||||||
|
border-top-left-radius: var(--p-inputgroup-addon-border-radius, 6px) !important;
|
||||||
|
border-bottom-left-radius: var(--p-inputgroup-addon-border-radius, 6px) !important;
|
||||||
|
border-top-right-radius: 0 !important;
|
||||||
|
border-bottom-right-radius: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
> .p-inputgroupaddon:last-child {
|
||||||
|
border-right: 0 none !important;
|
||||||
|
border-left: 1px solid var(--p-inputgroup-addon-border-color, var(--border-color)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Component/input before addon (addon positioned to the left)
|
||||||
|
> .p-component + .p-inputgroupaddon,
|
||||||
|
> .p-inputwrapper + .p-inputgroupaddon,
|
||||||
|
> .p-inputnumber + .p-inputgroupaddon,
|
||||||
|
> .p-floatlabel + .p-inputgroupaddon,
|
||||||
|
> input + .p-inputgroupaddon {
|
||||||
|
border-right: 0 none !important;
|
||||||
|
border-left: 1px solid var(--p-inputgroup-addon-border-color, var(--border-color)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Addon before component/input (component positioned to the left)
|
||||||
|
> .p-inputgroupaddon + .p-component,
|
||||||
|
> .p-inputgroupaddon + .p-inputwrapper,
|
||||||
|
> .p-inputgroupaddon + .p-inputnumber,
|
||||||
|
> .p-inputgroupaddon + .p-floatlabel,
|
||||||
|
> .p-inputgroupaddon + input {
|
||||||
|
border-right: 0 none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Focus state handling
|
||||||
|
> .p-component:focus,
|
||||||
|
> .p-component:focus-within,
|
||||||
|
> .p-inputwrapper:focus-within,
|
||||||
|
> .p-inputwrapper > .p-inputtext:focus,
|
||||||
|
> .p-inputwrapper > .p-inputnumber-input:focus,
|
||||||
|
> .p-inputnumber:focus-within,
|
||||||
|
> .p-inputnumber .p-inputnumber-input:focus,
|
||||||
|
> .p-floatlabel > .p-component:focus {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Vue FilePond RTL adjustment */
|
/* Vue FilePond RTL adjustment */
|
||||||
.filepond--root {
|
.filepond--root {
|
||||||
direction: rtl;
|
direction: rtl;
|
||||||
|
|||||||
@@ -294,12 +294,13 @@ const props = defineProps({
|
|||||||
courseId: { type: [String, Object], required: true },
|
courseId: { type: [String, Object], required: true },
|
||||||
professorId: { type: [String, Object], default: null },
|
professorId: { type: [String, Object], default: null },
|
||||||
defaultSessionCount: { type: Number, default: 12 },
|
defaultSessionCount: { type: Number, default: 12 },
|
||||||
|
startDate: { type: [String, Date], default: '' },
|
||||||
days: { type: Array, default: () => [] },
|
days: { type: Array, default: () => [] },
|
||||||
startTime: { type: String, default: '' },
|
startTime: { type: String, default: '' },
|
||||||
endTime: { type: String, default: '' }
|
endTime: { type: String, default: '' }
|
||||||
});
|
});
|
||||||
|
|
||||||
const { toPersianDigits, toLatinDigits, toGregorianIso, formatJalali, getTodayJalali } = usePersianDate();
|
const { toPersianDigits, toLatinDigits, toGregorianIso, formatJalali, getTodayJalali, parseToMoment, toJalaliPickerValue } = usePersianDate();
|
||||||
const { showSuccess, showError } = useToast();
|
const { showSuccess, showError } = useToast();
|
||||||
|
|
||||||
const sessions = ref([]);
|
const sessions = ref([]);
|
||||||
@@ -407,16 +408,17 @@ const weekdays = [
|
|||||||
|
|
||||||
const autoForm = reactive({
|
const autoForm = reactive({
|
||||||
sessionCount: props.defaultSessionCount || 12,
|
sessionCount: props.defaultSessionCount || 12,
|
||||||
startDate: getTodayJalali(),
|
startDate: toJalaliPickerValue(props.startDate) || getTodayJalali(),
|
||||||
startTime: props.startTime || '19:00',
|
startTime: props.startTime || '19:00',
|
||||||
endTime: props.endTime || '20:30',
|
endTime: props.endTime || '20:30',
|
||||||
selectedDays: props.days?.length ? [...props.days] : [6, 2]
|
selectedDays: props.days?.length ? props.days.map(Number) : [6, 2]
|
||||||
});
|
});
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => [props.days, props.startTime, props.endTime, props.defaultSessionCount],
|
() => [props.startDate, props.days, props.startTime, props.endTime, props.defaultSessionCount],
|
||||||
() => {
|
() => {
|
||||||
if (props.days?.length) autoForm.selectedDays = [...props.days];
|
if (props.startDate) autoForm.startDate = toJalaliPickerValue(props.startDate) || autoForm.startDate;
|
||||||
|
if (props.days?.length) autoForm.selectedDays = props.days.map(Number);
|
||||||
if (props.startTime) autoForm.startTime = props.startTime;
|
if (props.startTime) autoForm.startTime = props.startTime;
|
||||||
if (props.endTime) autoForm.endTime = props.endTime;
|
if (props.endTime) autoForm.endTime = props.endTime;
|
||||||
if (props.defaultSessionCount) autoForm.sessionCount = props.defaultSessionCount;
|
if (props.defaultSessionCount) autoForm.sessionCount = props.defaultSessionCount;
|
||||||
@@ -519,9 +521,8 @@ const generateSessionsPreview = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const list = [];
|
const list = [];
|
||||||
const latinStartDate = toLatinDigits(autoForm.startDate);
|
const currentMoment = parseToMoment(autoForm.startDate);
|
||||||
let currentMoment = moment(latinStartDate, ['jYYYY/jMM/jDD', 'YYYY-MM-DD', 'YYYY/MM/DD', moment.ISO_8601]);
|
if (!currentMoment || !currentMoment.isValid()) {
|
||||||
if (!currentMoment.isValid()) {
|
|
||||||
showError('تاریخ شروع نامعتبر است');
|
showError('تاریخ شروع نامعتبر است');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -532,7 +533,7 @@ const generateSessionsPreview = () => {
|
|||||||
|
|
||||||
while (count < targetCount && safetyLimit > 0) {
|
while (count < targetCount && safetyLimit > 0) {
|
||||||
safetyLimit--;
|
safetyLimit--;
|
||||||
if (autoForm.selectedDays.includes(currentMoment.day())) {
|
if (autoForm.selectedDays.map(Number).includes(currentMoment.day())) {
|
||||||
count++;
|
count++;
|
||||||
list.push({
|
list.push({
|
||||||
sessionNumber: count,
|
sessionNumber: count,
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
<!-- /src/components/common/SmsPreviewConfirmDialog.vue -->
|
||||||
|
<template>
|
||||||
|
<Dialog
|
||||||
|
:visible="visible"
|
||||||
|
modal
|
||||||
|
:header="title || 'پیشنمایش و تأیید ارسال پیامک'"
|
||||||
|
:style="{ width: '560px', maxWidth: '95vw' }"
|
||||||
|
:closable="!loading"
|
||||||
|
@update:visible="onUpdateVisible"
|
||||||
|
>
|
||||||
|
<div class="flex flex-column gap-3 py-1">
|
||||||
|
<!-- Recipient Header Card -->
|
||||||
|
<div class="flex align-items-center justify-content-between p-3 border-round-xl surface-ground border-1 border-color">
|
||||||
|
<div class="flex align-items-center gap-3">
|
||||||
|
<div class="w-2.5rem h-2.5rem border-round-lg flex align-items-center justify-content-center bg-primary-light text-primary font-bold">
|
||||||
|
<i class="pi pi-user text-lg"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-xs text-muted block">گیرنده پیامک</span>
|
||||||
|
<span class="font-bold text-color text-sm">{{ recipientName || 'گیرنده' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="recipientPhone" class="flex align-items-center gap-2" dir="ltr">
|
||||||
|
<i class="pi pi-phone text-muted text-xs"></i>
|
||||||
|
<span class="font-mono text-sm font-semibold text-color">{{ recipientPhone }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Exact SMS Message Preview Box -->
|
||||||
|
<div class="flex flex-column gap-2">
|
||||||
|
<div class="flex align-items-center justify-content-between">
|
||||||
|
<span class="text-xs font-bold text-muted flex align-items-center gap-1">
|
||||||
|
<i class="pi pi-envelope text-primary"></i>
|
||||||
|
متن دقیق ارسالی (با جایگذاری متغیرها):
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
v-if="messageText"
|
||||||
|
icon="pi pi-copy"
|
||||||
|
label="کپی متن"
|
||||||
|
text
|
||||||
|
size="small"
|
||||||
|
class="text-xs p-1"
|
||||||
|
@click="copyText"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sms-bubble p-4 border-round-xl surface-card border-1 border-primary-light shadow-1 line-height-3 text-sm text-color white-space-pre-wrap position-relative font-medium">
|
||||||
|
{{ messageText || 'متنی برای نمایش وجود ندارد.' }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex align-items-center justify-content-between px-1 text-xs text-muted">
|
||||||
|
<span>تعداد کاراکتر: <strong class="text-color">{{ toPersianDigits(charCount) }}</strong></span>
|
||||||
|
<span>تعداد بخش پیامک: <strong class="text-color">{{ toPersianDigits(partCount) }}</strong> صفحه</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Informational Alert -->
|
||||||
|
<div class="p-3 border-round-lg surface-100 border-1 border-dashed border-color text-xs text-muted flex align-items-center gap-2">
|
||||||
|
<i class="pi pi-info-circle text-primary text-base flex-shrink-0"></i>
|
||||||
|
<span>این پیامک بلافاصله از طریق درگاه پیامک ارسال شده و متن دقیق آن در بخش اطلاعیهها ثبت خواهد شد.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex justify-content-end gap-2 pt-2">
|
||||||
|
<Button
|
||||||
|
label="انصراف"
|
||||||
|
text
|
||||||
|
severity="secondary"
|
||||||
|
:disabled="loading"
|
||||||
|
@click="onCancel"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
:label="confirmLabel || 'ارسال پیامک'"
|
||||||
|
icon="pi pi-send"
|
||||||
|
severity="primary"
|
||||||
|
:loading="loading"
|
||||||
|
@click="onConfirm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import Dialog from 'primevue/dialog';
|
||||||
|
import Button from 'primevue/button';
|
||||||
|
import { usePersianDate } from '@/composables/usePersianDate';
|
||||||
|
import { useToast } from '@/composables/useToast';
|
||||||
|
|
||||||
|
const { toPersianDigits } = usePersianDate();
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
visible: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
default: 'پیشنمایش و تأیید ارسال پیامک'
|
||||||
|
},
|
||||||
|
recipientName: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
recipientPhone: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
messageText: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
loading: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
confirmLabel: {
|
||||||
|
type: String,
|
||||||
|
default: 'ارسال پیامک'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:visible', 'confirm', 'cancel']);
|
||||||
|
const { showSuccess } = useToast();
|
||||||
|
|
||||||
|
const charCount = computed(() => {
|
||||||
|
return (props.messageText || '').length;
|
||||||
|
});
|
||||||
|
|
||||||
|
const partCount = computed(() => {
|
||||||
|
const len = charCount.value;
|
||||||
|
if (len === 0) return 0;
|
||||||
|
if (len <= 70) return 1;
|
||||||
|
return Math.ceil(len / 67);
|
||||||
|
});
|
||||||
|
|
||||||
|
const onUpdateVisible = (val) => {
|
||||||
|
emit('update:visible', val);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onCancel = () => {
|
||||||
|
emit('cancel');
|
||||||
|
emit('update:visible', false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onConfirm = () => {
|
||||||
|
emit('confirm');
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyText = async () => {
|
||||||
|
if (!props.messageText) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(props.messageText);
|
||||||
|
showSuccess('متن پیامک کپی شد.');
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.sms-bubble {
|
||||||
|
background: linear-gradient(135deg, rgba(var(--primary-rgb, 59, 130, 246), 0.04) 0%, rgba(var(--primary-rgb, 59, 130, 246), 0.08) 100%);
|
||||||
|
border-right: 4px solid var(--primary-color, #3b82f6);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -47,7 +47,8 @@ const PAYMENT_LABELS = {
|
|||||||
partial: 'پیش پرداخت',
|
partial: 'پیش پرداخت',
|
||||||
pending: 'در انتظار پرداخت',
|
pending: 'در انتظار پرداخت',
|
||||||
overdue: 'معوق',
|
overdue: 'معوق',
|
||||||
cancelled: 'لغوشده'
|
cancelled: 'لغوشده',
|
||||||
|
reverted: 'مسترد شده'
|
||||||
};
|
};
|
||||||
|
|
||||||
const PENDING_STUDENT_LABELS = {
|
const PENDING_STUDENT_LABELS = {
|
||||||
@@ -67,6 +68,7 @@ const severity = computed(() => {
|
|||||||
if (val === 'pending' || val === 'در انتظار پرداخت') return 'info';
|
if (val === 'pending' || val === 'در انتظار پرداخت') return 'info';
|
||||||
if (val === 'overdue' || val === 'معوق شده' || val === 'معوق') return 'danger';
|
if (val === 'overdue' || val === 'معوق شده' || val === 'معوق') return 'danger';
|
||||||
if (val === 'cancelled' || val === 'canceled' || val === 'لغوشده') return 'secondary';
|
if (val === 'cancelled' || val === 'canceled' || val === 'لغوشده') return 'secondary';
|
||||||
|
if (val === 'reverted' || val === 'مسترد شده' || val === 'مسترد') return 'warn';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (props.type === 'session') {
|
if (props.type === 'session') {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
<div class="flex-grow-1">
|
<div class="flex-grow-1">
|
||||||
<span class="text-muted text-xs block mb-1">{{ label }}</span>
|
<span class="text-muted text-xs block mb-1">{{ label }}</span>
|
||||||
<span class="text-xl font-bold text-color">{{ formattedValue }}</span>
|
<span class="text-xl font-bold text-color">{{ formattedValue }}</span>
|
||||||
|
<span v-if="hint" class="text-xs text-muted block mt-1">{{ hint }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-icon w-3rem h-3rem border-round-xl flex align-items-center justify-content-center flex-shrink-0" :class="bgClass">
|
<div class="stat-icon w-3rem h-3rem border-round-xl flex align-items-center justify-content-center flex-shrink-0" :class="bgClass">
|
||||||
<i :class="[icon, 'text-xl', iconClass]"></i>
|
<i :class="[icon, 'text-xl', iconClass]"></i>
|
||||||
@@ -20,10 +21,19 @@ const props = defineProps({
|
|||||||
value: { type: Number, default: 0 },
|
value: { type: Number, default: 0 },
|
||||||
icon: { type: String, default: 'pi pi-wallet' },
|
icon: { type: String, default: 'pi pi-wallet' },
|
||||||
bgClass: { type: String, default: 'bg-blue-100' },
|
bgClass: { type: String, default: 'bg-blue-100' },
|
||||||
iconClass: { type: String, default: 'text-blue-600' }
|
iconClass: { type: String, default: 'text-blue-600' },
|
||||||
|
/** 'currency' (default, appends تومان) | 'number' (plain count) | 'text' (uses displayValue verbatim) */
|
||||||
|
format: { type: String, default: 'currency' },
|
||||||
|
/** Overrides the computed value entirely, e.g. a "12 / 20" ratio string. */
|
||||||
|
displayValue: { type: String, default: '' },
|
||||||
|
hint: { type: String, default: '' }
|
||||||
});
|
});
|
||||||
|
|
||||||
const { toPersianDigits } = usePersianDate();
|
const { toPersianDigits } = usePersianDate();
|
||||||
|
|
||||||
const formattedValue = computed(() => `${toPersianDigits(Math.round(props.value || 0).toLocaleString())} تومان`);
|
const formattedValue = computed(() => {
|
||||||
|
if (props.displayValue) return props.displayValue;
|
||||||
|
if (props.format === 'number') return toPersianDigits(Math.round(props.value || 0).toLocaleString());
|
||||||
|
return `${toPersianDigits(Math.round(props.value || 0).toLocaleString())} تومان`;
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<!-- /src/components/financialReports/charts/ForecastChart.vue -->
|
||||||
|
<!-- Bar chart of expected future cash inflow, bucketed by the upcoming due-date week. -->
|
||||||
|
<template>
|
||||||
|
<div class="chart-wrap" dir="ltr" :style="{ height: height + 'px' }">
|
||||||
|
<Bar :data="chartData" :options="options" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { Bar } from 'vue-chartjs';
|
||||||
|
import { usePersianDate } from '@/composables/usePersianDate';
|
||||||
|
import { CHART_COLORS } from '@/utils/chartTheme';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
labels: { type: Array, default: () => [] },
|
||||||
|
amounts: { type: Array, default: () => [] },
|
||||||
|
counts: { type: Array, default: () => [] },
|
||||||
|
height: { type: Number, default: 240 }
|
||||||
|
});
|
||||||
|
|
||||||
|
const { toPersianDigits } = usePersianDate();
|
||||||
|
const formatToman = (v) => `${toPersianDigits(Math.round(v || 0).toLocaleString())} تومان`;
|
||||||
|
|
||||||
|
const chartData = computed(() => ({
|
||||||
|
labels: props.labels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'دریافتی پیشبینیشده',
|
||||||
|
data: props.amounts,
|
||||||
|
backgroundColor: CHART_COLORS.sessionIncome,
|
||||||
|
borderRadius: 6,
|
||||||
|
maxBarThickness: 40
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}));
|
||||||
|
|
||||||
|
const options = computed(() => ({
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: { display: false },
|
||||||
|
tooltip: {
|
||||||
|
rtl: true,
|
||||||
|
textDirection: 'rtl',
|
||||||
|
callbacks: {
|
||||||
|
label: (ctx) => `${formatToman(ctx.parsed.y)}`,
|
||||||
|
afterLabel: (ctx) => {
|
||||||
|
const count = props.counts[ctx.dataIndex];
|
||||||
|
return count ? `${toPersianDigits(count)} قسط در انتظار` : '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: { grid: { display: false }, ticks: { color: CHART_COLORS.muted } },
|
||||||
|
y: {
|
||||||
|
grid: { color: CHART_COLORS.gridLine, drawBorder: false },
|
||||||
|
ticks: { color: CHART_COLORS.muted, callback: (v) => toPersianDigits(Number(v).toLocaleString()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.chart-wrap {
|
||||||
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
<!-- /src/components/financialReports/charts/IncomeByClassChart.vue -->
|
||||||
|
<!-- Horizontal bar chart ranking classes by received revenue vs. outstanding receivables. -->
|
||||||
|
<template>
|
||||||
|
<div class="chart-wrap" dir="ltr" :style="{ height: computedHeight + 'px' }">
|
||||||
|
<Bar v-if="rows.length" :data="chartData" :options="options" />
|
||||||
|
<div v-else class="flex align-items-center justify-content-center h-full text-muted text-sm">
|
||||||
|
دادهای برای نمایش وجود ندارد
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { Bar } from 'vue-chartjs';
|
||||||
|
import { usePersianDate } from '@/composables/usePersianDate';
|
||||||
|
import { CHART_COLORS } from '@/utils/chartTheme';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
rows: { type: Array, default: () => [] }, // [{ className, actualReceivedRevenue, pendingReceivables }]
|
||||||
|
limit: { type: Number, default: 8 }
|
||||||
|
});
|
||||||
|
|
||||||
|
const { toPersianDigits } = usePersianDate();
|
||||||
|
const formatToman = (v) => `${toPersianDigits(Math.round(v || 0).toLocaleString())} تومان`;
|
||||||
|
|
||||||
|
const topRows = computed(() => props.rows.slice(0, props.limit));
|
||||||
|
const computedHeight = computed(() => Math.max(220, topRows.value.length * 46));
|
||||||
|
|
||||||
|
const chartData = computed(() => ({
|
||||||
|
labels: topRows.value.map((r) => r.className),
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'وصولشده',
|
||||||
|
data: topRows.value.map((r) => r.actualReceivedRevenue),
|
||||||
|
backgroundColor: CHART_COLORS.received,
|
||||||
|
borderRadius: 5,
|
||||||
|
maxBarThickness: 18
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'مطالبات معوق',
|
||||||
|
data: topRows.value.map((r) => r.pendingReceivables),
|
||||||
|
backgroundColor: CHART_COLORS.outstanding,
|
||||||
|
borderRadius: 5,
|
||||||
|
maxBarThickness: 18
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}));
|
||||||
|
|
||||||
|
const options = computed(() => ({
|
||||||
|
indexAxis: 'y',
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
position: 'top',
|
||||||
|
align: 'end',
|
||||||
|
labels: { usePointStyle: true, pointStyle: 'circle', boxWidth: 8, boxHeight: 8 }
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
rtl: true,
|
||||||
|
textDirection: 'rtl',
|
||||||
|
callbacks: {
|
||||||
|
label: (ctx) => `${ctx.dataset.label}: ${formatToman(ctx.parsed.x)}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: {
|
||||||
|
stacked: false,
|
||||||
|
grid: { color: CHART_COLORS.gridLine, drawBorder: false },
|
||||||
|
ticks: { color: CHART_COLORS.muted, callback: (v) => toPersianDigits(Number(v).toLocaleString()) }
|
||||||
|
},
|
||||||
|
y: {
|
||||||
|
grid: { display: false },
|
||||||
|
ticks: { color: CHART_COLORS.muted }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.chart-wrap {
|
||||||
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
<!-- /src/components/financialReports/charts/IncomeTrendChart.vue -->
|
||||||
|
<!-- Line chart comparing cash actually received against accrued income earned from held sessions. -->
|
||||||
|
<template>
|
||||||
|
<div class="chart-wrap" dir="ltr" :style="{ height: height + 'px' }">
|
||||||
|
<Line :data="chartData" :options="options" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { Line } from 'vue-chartjs';
|
||||||
|
import { usePersianDate } from '@/composables/usePersianDate';
|
||||||
|
import { CHART_COLORS } from '@/utils/chartTheme';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
labels: { type: Array, default: () => [] },
|
||||||
|
received: { type: Array, default: () => [] },
|
||||||
|
sessionIncome: { type: Array, default: () => [] },
|
||||||
|
height: { type: Number, default: 260 }
|
||||||
|
});
|
||||||
|
|
||||||
|
const { toPersianDigits } = usePersianDate();
|
||||||
|
const formatToman = (v) => `${toPersianDigits(Math.round(v || 0).toLocaleString())} تومان`;
|
||||||
|
|
||||||
|
const chartData = computed(() => ({
|
||||||
|
labels: props.labels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'وجوه دریافتی (نقدی)',
|
||||||
|
data: props.received,
|
||||||
|
borderColor: CHART_COLORS.received,
|
||||||
|
backgroundColor: CHART_COLORS.receivedSoft,
|
||||||
|
tension: 0.35,
|
||||||
|
fill: true,
|
||||||
|
pointRadius: 2,
|
||||||
|
pointHoverRadius: 5,
|
||||||
|
borderWidth: 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'درآمد تعهدی (بر اساس جلسات برگزارشده)',
|
||||||
|
data: props.sessionIncome,
|
||||||
|
borderColor: CHART_COLORS.sessionIncome,
|
||||||
|
backgroundColor: CHART_COLORS.sessionIncomeSoft,
|
||||||
|
tension: 0.35,
|
||||||
|
fill: true,
|
||||||
|
pointRadius: 2,
|
||||||
|
pointHoverRadius: 5,
|
||||||
|
borderWidth: 2,
|
||||||
|
borderDash: [5, 4]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}));
|
||||||
|
|
||||||
|
const options = computed(() => ({
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
interaction: { mode: 'index', intersect: false },
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
position: 'top',
|
||||||
|
align: 'end',
|
||||||
|
labels: { usePointStyle: true, pointStyle: 'circle', boxWidth: 8, boxHeight: 8 }
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
rtl: true,
|
||||||
|
textDirection: 'rtl',
|
||||||
|
callbacks: {
|
||||||
|
label: (ctx) => `${ctx.dataset.label}: ${formatToman(ctx.parsed.y)}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: { grid: { display: false }, ticks: { color: CHART_COLORS.muted, maxRotation: 0, autoSkip: true } },
|
||||||
|
y: {
|
||||||
|
grid: { color: CHART_COLORS.gridLine, drawBorder: false },
|
||||||
|
ticks: {
|
||||||
|
color: CHART_COLORS.muted,
|
||||||
|
callback: (v) => toPersianDigits(Number(v).toLocaleString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.chart-wrap {
|
||||||
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
<!-- /src/components/financialReports/charts/MonthlyBreakdownChart.vue -->
|
||||||
|
<!-- Mixed bar+line chart: monthly received / professor payouts / general expenses (bars) vs. net profit (line). -->
|
||||||
|
<template>
|
||||||
|
<div class="chart-wrap" dir="ltr" :style="{ height: height + 'px' }">
|
||||||
|
<Chart type="bar" :data="chartData" :options="options" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { Chart } from 'vue-chartjs';
|
||||||
|
import { usePersianDate } from '@/composables/usePersianDate';
|
||||||
|
import { CHART_COLORS } from '@/utils/chartTheme';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
labels: { type: Array, default: () => [] },
|
||||||
|
received: { type: Array, default: () => [] },
|
||||||
|
professorPayouts: { type: Array, default: () => [] },
|
||||||
|
generalExpenses: { type: Array, default: () => [] },
|
||||||
|
netProfit: { type: Array, default: () => [] },
|
||||||
|
height: { type: Number, default: 280 }
|
||||||
|
});
|
||||||
|
|
||||||
|
const { toPersianDigits } = usePersianDate();
|
||||||
|
const formatToman = (v) => `${toPersianDigits(Math.round(v || 0).toLocaleString())} تومان`;
|
||||||
|
|
||||||
|
const chartData = computed(() => ({
|
||||||
|
labels: props.labels,
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
type: 'bar',
|
||||||
|
label: 'وجوه دریافتی',
|
||||||
|
data: props.received,
|
||||||
|
backgroundColor: CHART_COLORS.received,
|
||||||
|
borderRadius: 6,
|
||||||
|
order: 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'bar',
|
||||||
|
label: 'سهم اساتید',
|
||||||
|
data: props.professorPayouts,
|
||||||
|
backgroundColor: CHART_COLORS.payout,
|
||||||
|
borderRadius: 6,
|
||||||
|
order: 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'bar',
|
||||||
|
label: 'هزینههای عمومی',
|
||||||
|
data: props.generalExpenses,
|
||||||
|
backgroundColor: CHART_COLORS.expense,
|
||||||
|
borderRadius: 6,
|
||||||
|
order: 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'line',
|
||||||
|
label: 'سود خالص',
|
||||||
|
data: props.netProfit,
|
||||||
|
borderColor: CHART_COLORS.netProfit,
|
||||||
|
backgroundColor: CHART_COLORS.netProfit,
|
||||||
|
borderWidth: 3,
|
||||||
|
tension: 0.3,
|
||||||
|
pointRadius: 4,
|
||||||
|
pointHoverRadius: 6,
|
||||||
|
fill: false,
|
||||||
|
order: 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}));
|
||||||
|
|
||||||
|
const options = computed(() => ({
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
interaction: { mode: 'index', intersect: false },
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
position: 'top',
|
||||||
|
align: 'end',
|
||||||
|
labels: { usePointStyle: true, pointStyle: 'circle', boxWidth: 8, boxHeight: 8 }
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
rtl: true,
|
||||||
|
textDirection: 'rtl',
|
||||||
|
callbacks: {
|
||||||
|
label: (ctx) => `${ctx.dataset.label}: ${formatToman(ctx.parsed.y)}`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
x: { grid: { display: false }, ticks: { color: CHART_COLORS.muted } },
|
||||||
|
y: {
|
||||||
|
grid: { color: CHART_COLORS.gridLine, drawBorder: false },
|
||||||
|
ticks: {
|
||||||
|
color: CHART_COLORS.muted,
|
||||||
|
callback: (v) => toPersianDigits(Number(v).toLocaleString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.chart-wrap {
|
||||||
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
<!-- /src/components/financialReports/charts/PaymentStatusChart.vue -->
|
||||||
|
<!-- Doughnut chart of the institute's payment records grouped by status (amount-weighted). -->
|
||||||
|
<template>
|
||||||
|
<div class="chart-wrap" dir="ltr" :style="{ height: height + 'px' }">
|
||||||
|
<Doughnut v-if="hasData" :data="chartData" :options="options" />
|
||||||
|
<div v-else class="flex align-items-center justify-content-center h-full text-muted text-sm">
|
||||||
|
دادهای برای نمایش وجود ندارد
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { Doughnut } from 'vue-chartjs';
|
||||||
|
import { usePersianDate } from '@/composables/usePersianDate';
|
||||||
|
import { STATUS_COLORS, STATUS_LABELS_FA } from '@/utils/chartTheme';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
breakdown: { type: Array, default: () => [] }, // [{ status, count, amount }]
|
||||||
|
height: { type: Number, default: 240 }
|
||||||
|
});
|
||||||
|
|
||||||
|
const { toPersianDigits } = usePersianDate();
|
||||||
|
const formatToman = (v) => `${toPersianDigits(Math.round(v || 0).toLocaleString())} تومان`;
|
||||||
|
|
||||||
|
const hasData = computed(() => props.breakdown.some((b) => b.amount > 0));
|
||||||
|
|
||||||
|
const chartData = computed(() => ({
|
||||||
|
labels: props.breakdown.map((b) => STATUS_LABELS_FA[b.status] || b.status),
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
data: props.breakdown.map((b) => b.amount),
|
||||||
|
backgroundColor: props.breakdown.map((b) => STATUS_COLORS[b.status] || '#94a3b8'),
|
||||||
|
borderWidth: 0,
|
||||||
|
hoverOffset: 6
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}));
|
||||||
|
|
||||||
|
const options = computed(() => ({
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
cutout: '65%',
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
position: 'bottom',
|
||||||
|
labels: { usePointStyle: true, pointStyle: 'circle', boxWidth: 8, boxHeight: 8 }
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
rtl: true,
|
||||||
|
textDirection: 'rtl',
|
||||||
|
callbacks: {
|
||||||
|
label: (ctx) => {
|
||||||
|
const item = props.breakdown[ctx.dataIndex];
|
||||||
|
const count = item ? toPersianDigits(item.count) : '';
|
||||||
|
return `${ctx.label}: ${formatToman(ctx.parsed)} (${count} پرداخت)`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.chart-wrap {
|
||||||
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -122,6 +122,7 @@ const menuGroups = computed(() => {
|
|||||||
{ label: 'مدیریت اساتید', icon: 'pi pi-id-card', to: '/professors' },
|
{ label: 'مدیریت اساتید', icon: 'pi pi-id-card', to: '/professors' },
|
||||||
{ label: 'دورههای آموزشی', icon: 'pi pi-book', to: '/courses' },
|
{ label: 'دورههای آموزشی', icon: 'pi pi-book', to: '/courses' },
|
||||||
{ label: 'کلاسها', icon: 'pi pi-desktop', to: '/classes' },
|
{ label: 'کلاسها', icon: 'pi pi-desktop', to: '/classes' },
|
||||||
|
{ label: 'لیست انتظار', icon: 'pi pi-clock', to: '/waitlist', permission: PERMISSIONS.WAITLIST_READ },
|
||||||
{ label: 'جلسات آموزشی', icon: 'pi pi-calendar', to: '/sessions' }
|
{ label: 'جلسات آموزشی', icon: 'pi pi-calendar', to: '/sessions' }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -141,6 +142,7 @@ const menuGroups = computed(() => {
|
|||||||
key: 'system',
|
key: 'system',
|
||||||
label: 'مدیریت سیستم',
|
label: 'مدیریت سیستم',
|
||||||
items: [
|
items: [
|
||||||
|
{ label: 'ورود و خروج کارمندان', icon: 'pi pi-clock', to: '/employee-timings', permission: PERMISSIONS.EMPLOYEE_TIMINGS_READ },
|
||||||
{ label: 'گزارش فعالیتها', icon: 'pi pi-history', to: '/logs', permission: PERMISSIONS.LOGS_READ },
|
{ label: 'گزارش فعالیتها', icon: 'pi pi-history', to: '/logs', permission: PERMISSIONS.LOGS_READ },
|
||||||
{ label: 'نقشها و دسترسیها', icon: 'pi pi-shield', to: '/roles' }
|
{ label: 'نقشها و دسترسیها', icon: 'pi pi-shield', to: '/roles' }
|
||||||
]
|
]
|
||||||
@@ -165,6 +167,7 @@ const menuGroups = computed(() => {
|
|||||||
|
|
||||||
if (permissionStore.roleName === 'SuperAdmin') {
|
if (permissionStore.roleName === 'SuperAdmin') {
|
||||||
const systemGroup = groups.find((g) => g.key === 'system');
|
const systemGroup = groups.find((g) => g.key === 'system');
|
||||||
|
systemGroup.items.push({ label: 'قالبهای اعلان', icon: 'pi pi-send', to: '/notification-templates' });
|
||||||
systemGroup.items.push({ label: 'تنظیمات', icon: 'pi pi-cog', to: '/settings' });
|
systemGroup.items.push({ label: 'تنظیمات', icon: 'pi pi-cog', to: '/settings' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,701 @@
|
|||||||
|
<!-- /src/components/payments/QuickEditPaymentDialog.vue -->
|
||||||
|
<template>
|
||||||
|
<Dialog
|
||||||
|
:visible="visible"
|
||||||
|
@update:visible="$emit('update:visible', $event)"
|
||||||
|
:header="dialogTitle"
|
||||||
|
modal
|
||||||
|
:style="{ width: '900px', maxWidth: '95vw' }"
|
||||||
|
:closable="true"
|
||||||
|
@hide="handleClose"
|
||||||
|
>
|
||||||
|
<div v-if="loading && !payment" class="py-5 text-center">
|
||||||
|
<i class="pi pi-spin pi-spinner text-3xl text-primary mb-2"></i>
|
||||||
|
<p class="text-sm text-muted">در حال دریافت اطلاعات صورتحساب…</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="payment" class="quick-edit-content flex flex-column gap-4 py-2">
|
||||||
|
<!-- Top Overview Cards -->
|
||||||
|
<div class="grid">
|
||||||
|
<div class="col-12 md:col-6 lg:col-3">
|
||||||
|
<div class="surface-100 p-3 border-round flex flex-column gap-1">
|
||||||
|
<span class="text-xs text-muted">دانشجو / کاربر</span>
|
||||||
|
<span class="font-bold text-color text-sm">{{ payment.user?.name || payment.userName || 'کاربر' }}</span>
|
||||||
|
<span v-if="payment.user?.phoneNumber" class="text-xs text-muted" dir="ltr">{{ payment.user.phoneNumber }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 md:col-6 lg:col-3">
|
||||||
|
<div class="surface-100 p-3 border-round flex flex-column gap-1">
|
||||||
|
<span class="text-xs text-muted">دوره / کلاس</span>
|
||||||
|
<div v-if="payment.classes && payment.classes.length" class="flex flex-wrap gap-1 mt-1">
|
||||||
|
<Tag v-for="c in payment.classes" :key="c._id || c" :value="c.name || 'کلاس'" severity="info" class="text-xs" />
|
||||||
|
</div>
|
||||||
|
<span v-else-if="payment.course" class="font-bold text-color text-sm">{{ payment.course?.title }}</span>
|
||||||
|
<Tag v-if="payment.type === 'waiting_list'" value="لیست انتظار" severity="warn" class="text-xs w-max mt-1" />
|
||||||
|
<span v-else-if="!payment.classes?.length && !payment.course" class="text-xs text-muted">—</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 md:col-6 lg:col-3">
|
||||||
|
<div class="surface-100 p-3 border-round flex flex-column gap-1">
|
||||||
|
<span class="text-xs text-muted">مبلغ قابل پرداخت / پرداختی</span>
|
||||||
|
<span class="font-bold text-color text-sm">{{ toPersianDigits(payableAmount.toLocaleString()) }} تومان</span>
|
||||||
|
<span class="text-xs text-green-600 font-semibold">
|
||||||
|
دریافتی: {{ toPersianDigits((payment.paidAmount || 0).toLocaleString()) }} تومان
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 md:col-6 lg:col-3">
|
||||||
|
<div class="surface-100 p-3 border-round flex flex-column gap-1">
|
||||||
|
<span class="text-xs text-muted">مانده / وضعیت</span>
|
||||||
|
<span class="font-bold text-sm" :class="remainingAmount > 0 ? 'text-red-500' : 'text-green-600'">
|
||||||
|
مانده: {{ toPersianDigits(remainingAmount.toLocaleString()) }} تومان
|
||||||
|
</span>
|
||||||
|
<div class="mt-1">
|
||||||
|
<StatusTag :status="payment.status" type="payment" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Payment Main Edit Section -->
|
||||||
|
<div class="p-3 border-1 border-color border-round">
|
||||||
|
<div class="flex align-items-center justify-content-between mb-3">
|
||||||
|
<h4 class="text-sm font-bold text-color m-0">مشخصات اصلی صورتحساب</h4>
|
||||||
|
<Tag v-if="payment.uniqueCode" :value="`کد: ${payment.uniqueCode}`" severity="secondary" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
<div class="col-12 sm:col-6 md:col-4 flex flex-column gap-2">
|
||||||
|
<label class="font-semibold text-xs">مبلغ کل (تومان) *</label>
|
||||||
|
<InputGroup>
|
||||||
|
<InputNumber v-model="form.amount" class="w-full text-sm" :min="0" />
|
||||||
|
<InputGroupAddon>تومان</InputGroupAddon>
|
||||||
|
</InputGroup>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 sm:col-6 md:col-4 flex flex-column gap-2">
|
||||||
|
<label class="font-semibold text-xs">مبلغ تخفیف (تومان)</label>
|
||||||
|
<InputGroup>
|
||||||
|
<InputNumber v-model="form.discount" class="w-full text-sm" :min="0" :max="form.amount || 0" />
|
||||||
|
<InputGroupAddon>تومان</InputGroupAddon>
|
||||||
|
</InputGroup>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 sm:col-6 md:col-4 flex flex-column gap-2">
|
||||||
|
<label class="font-semibold text-xs">تاریخ سررسید</label>
|
||||||
|
<DatePicker v-model="form.dueDate" class="w-full text-sm" :placeholder="getTodayJalali()" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 sm:col-6 md:col-4 flex flex-column gap-2">
|
||||||
|
<label class="font-semibold text-xs">وضعیت صورتحساب</label>
|
||||||
|
<Dropdown
|
||||||
|
v-model="form.status"
|
||||||
|
:options="paymentStatusOptions"
|
||||||
|
optionLabel="label"
|
||||||
|
optionValue="value"
|
||||||
|
class="w-full text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 sm:col-6 md:col-4 flex flex-column gap-2">
|
||||||
|
<label class="font-semibold text-xs">نوع صورتحساب</label>
|
||||||
|
<Dropdown
|
||||||
|
v-model="form.type"
|
||||||
|
:options="paymentTypeOptions"
|
||||||
|
optionLabel="label"
|
||||||
|
optionValue="value"
|
||||||
|
class="w-full text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 md:col-4 flex flex-column gap-2">
|
||||||
|
<label class="font-semibold text-xs">مبلغ نهایی پس از تخفیف</label>
|
||||||
|
<div class="p-2 border-round surface-100 font-bold text-sm text-color">
|
||||||
|
{{ toPersianDigits(computedFormPayable.toLocaleString()) }} تومان
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 flex flex-column gap-2">
|
||||||
|
<label class="font-semibold text-xs">یادداشت صورتحساب</label>
|
||||||
|
<Textarea v-model="form.notes" rows="2" class="w-full text-sm" placeholder="توضیحات و یادداشت داخلی…" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-content-end gap-2 mt-3 pt-2 border-top-1 border-color">
|
||||||
|
<Button
|
||||||
|
label="ذخیره مشخصات صورتحساب"
|
||||||
|
icon="pi pi-check"
|
||||||
|
size="small"
|
||||||
|
:loading="savingPayment"
|
||||||
|
@click="handleSavePayment"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Transactions List & Management Section -->
|
||||||
|
<div class="p-3 border-1 border-color border-round">
|
||||||
|
<div class="flex align-items-center justify-content-between mb-3">
|
||||||
|
<div>
|
||||||
|
<h4 class="text-sm font-bold text-color m-0">تراکنشهای صورتحساب</h4>
|
||||||
|
<span class="text-xs text-muted">تراکنشهای پرداختی، معلق، لغوشده یا مسترد شده</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
label="ثبت تراکنش جدید"
|
||||||
|
icon="pi pi-plus"
|
||||||
|
size="small"
|
||||||
|
severity="success"
|
||||||
|
@click="openAddTransactionModal"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataTable
|
||||||
|
:value="payment.transactions || []"
|
||||||
|
class="p-datatable-sm text-xs"
|
||||||
|
emptyMessage="تراکنشی برای این صورتحساب ثبت نشده است"
|
||||||
|
>
|
||||||
|
<Column field="amount" header="مبلغ تراکنش">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<span :class="{ 'line-through text-muted opacity-60': isTransactionCancelled(data) }" class="font-bold">
|
||||||
|
{{ toPersianDigits((data.amount || 0).toLocaleString()) }} تومان
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<Column field="status" header="وضعیت">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<StatusTag :status="data.status || (data.date ? 'paid' : 'pending')" type="payment" />
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<Column field="method" header="روش پرداخت">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<Tag
|
||||||
|
v-if="data.method"
|
||||||
|
:value="data.method === 'online' ? 'آنلاین' : (data.method === 'card' ? 'کارت به کارت' : 'نقدی')"
|
||||||
|
severity="info"
|
||||||
|
class="text-xs"
|
||||||
|
/>
|
||||||
|
<span v-else class="text-muted">—</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<Column field="receiptNumber" header="شماره پیگیری">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<span dir="ltr">{{ toPersianDigits(data.receiptNumber || '—') }}</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<Column field="dueDate" header="سررسید">
|
||||||
|
<template #body="{ data }">
|
||||||
|
{{ formatJalali(data.dueDate) }}
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<Column field="date" header="تاریخ پرداخت">
|
||||||
|
<template #body="{ data }">
|
||||||
|
{{ data.date ? formatJalali(data.date) : '—' }}
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<Column field="notes" header="یادداشت">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<span class="white-space-nowrap overflow-hidden text-overflow-ellipsis block" style="max-width: 130px">
|
||||||
|
{{ data.notes || '—' }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<Column header="عملیات" style="width: 140px">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<div class="flex align-items-center gap-1">
|
||||||
|
<Button
|
||||||
|
v-if="!isTransactionCancelled(data)"
|
||||||
|
icon="pi pi-pencil"
|
||||||
|
text
|
||||||
|
rounded
|
||||||
|
size="small"
|
||||||
|
severity="secondary"
|
||||||
|
v-tooltip.top="'ویرایش تراکنش'"
|
||||||
|
@click="openEditTransactionModal(data)"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
v-if="!isTransactionCancelled(data)"
|
||||||
|
icon="pi pi-ban"
|
||||||
|
text
|
||||||
|
rounded
|
||||||
|
size="small"
|
||||||
|
severity="danger"
|
||||||
|
v-tooltip.top="'لغو تراکنش'"
|
||||||
|
@click="handleCancelTransaction(data)"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
v-if="!isTransactionCancelled(data)"
|
||||||
|
icon="pi pi-replay"
|
||||||
|
text
|
||||||
|
rounded
|
||||||
|
size="small"
|
||||||
|
severity="warn"
|
||||||
|
v-tooltip.top="'استرداد تراکنش'"
|
||||||
|
@click="handleRevertTransaction(data)"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
icon="pi pi-trash"
|
||||||
|
text
|
||||||
|
rounded
|
||||||
|
size="small"
|
||||||
|
severity="danger"
|
||||||
|
v-tooltip.top="'حذف تراکنش'"
|
||||||
|
@click="handleDeleteTransaction(data)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
</DataTable>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex justify-content-between align-items-center w-full">
|
||||||
|
<Button
|
||||||
|
label="مشاهده صفحه کامل صورتحساب"
|
||||||
|
icon="pi pi-external-link"
|
||||||
|
text
|
||||||
|
size="small"
|
||||||
|
@click="navigateToFullPage"
|
||||||
|
/>
|
||||||
|
<Button label="بستن" severity="secondary" text @click="$emit('update:visible', false)" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Sub-Modal: Add Transaction -->
|
||||||
|
<Dialog v-model:visible="showAddTrxModal" header="ثبت تراکنش جدید" modal :style="{ width: '480px' }">
|
||||||
|
<div class="flex flex-column gap-3 py-2">
|
||||||
|
<div class="flex flex-column gap-2">
|
||||||
|
<label class="font-semibold text-sm">مبلغ واریزی (تومان) *</label>
|
||||||
|
<InputGroup>
|
||||||
|
<InputNumber v-model="trxAddForm.amount" class="w-full text-sm" :min="1" />
|
||||||
|
<InputGroupAddon>تومان</InputGroupAddon>
|
||||||
|
</InputGroup>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-column gap-2">
|
||||||
|
<label class="font-semibold text-sm">وضعیت تراکنش *</label>
|
||||||
|
<Dropdown v-model="trxAddForm.status" :options="trxStatusOptions" optionLabel="label" optionValue="value" class="w-full text-sm" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="trxAddForm.status === 'paid'" class="flex flex-column gap-2">
|
||||||
|
<label class="font-semibold text-sm">روش پرداخت *</label>
|
||||||
|
<Dropdown v-model="trxAddForm.method" :options="methodOptions" optionLabel="label" optionValue="value" class="w-full text-sm" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-column gap-2">
|
||||||
|
<label class="font-semibold text-sm">تاریخ سررسید *</label>
|
||||||
|
<DatePicker v-model="trxAddForm.dueDate" class="w-full text-sm" :placeholder="getTodayJalali()" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="trxAddForm.status === 'paid'" class="flex flex-column gap-2">
|
||||||
|
<label class="font-semibold text-sm">تاریخ پرداخت</label>
|
||||||
|
<DatePicker v-model="trxAddForm.date" class="w-full text-sm" :placeholder="getTodayJalali()" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="trxAddForm.status === 'paid'" class="flex flex-column gap-2">
|
||||||
|
<label class="font-semibold text-sm">شماره فیش / پیگیری</label>
|
||||||
|
<InputText v-model.trim="trxAddForm.receiptNumber" class="w-full text-sm" dir="ltr" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-column gap-2">
|
||||||
|
<label class="font-semibold text-sm">یادداشت</label>
|
||||||
|
<Textarea v-model="trxAddForm.notes" rows="2" class="w-full text-sm" placeholder="توضیحات تراکنش…" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NotifyChannelsField :notify="trxAddNotify" />
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<Button label="انصراف" text severity="secondary" @click="showAddTrxModal = false" />
|
||||||
|
<Button label="ثبت تراکنش" icon="pi pi-check" severity="success" :loading="savingTrx" @click="submitAddTransaction" />
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<!-- Sub-Modal: Edit Transaction -->
|
||||||
|
<Dialog v-model:visible="showEditTrxModal" header="ویرایش تراکنش" modal :style="{ width: '480px' }">
|
||||||
|
<div class="flex flex-column gap-3 py-2" v-if="editingTrx">
|
||||||
|
<div class="flex flex-column gap-2">
|
||||||
|
<label class="font-semibold text-sm">مبلغ (تومان) *</label>
|
||||||
|
<InputGroup>
|
||||||
|
<InputNumber v-model="trxEditForm.amount" class="w-full text-sm" :min="1" />
|
||||||
|
<InputGroupAddon>تومان</InputGroupAddon>
|
||||||
|
</InputGroup>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-column gap-2">
|
||||||
|
<label class="font-semibold text-sm">وضعیت تراکنش</label>
|
||||||
|
<Dropdown v-model="trxEditForm.status" :options="trxFullStatusOptions" optionLabel="label" optionValue="value" class="w-full text-sm" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="trxEditForm.status === 'paid'" class="flex flex-column gap-2">
|
||||||
|
<label class="font-semibold text-sm">روش پرداخت</label>
|
||||||
|
<Dropdown v-model="trxEditForm.method" :options="methodOptions" optionLabel="label" optionValue="value" class="w-full text-sm" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-column gap-2">
|
||||||
|
<label class="font-semibold text-sm">تاریخ سررسید</label>
|
||||||
|
<DatePicker v-model="trxEditForm.dueDate" class="w-full text-sm" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="trxEditForm.status === 'paid'" class="flex flex-column gap-2">
|
||||||
|
<label class="font-semibold text-sm">تاریخ پرداخت</label>
|
||||||
|
<DatePicker v-model="trxEditForm.date" class="w-full text-sm" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="trxEditForm.status === 'paid'" class="flex flex-column gap-2">
|
||||||
|
<label class="font-semibold text-sm">شماره فیش / پیگیری</label>
|
||||||
|
<InputText v-model.trim="trxEditForm.receiptNumber" class="w-full text-sm" dir="ltr" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-column gap-2">
|
||||||
|
<label class="font-semibold text-sm">یادداشت</label>
|
||||||
|
<Textarea v-model="trxEditForm.notes" rows="2" class="w-full text-sm" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<Button label="انصراف" text severity="secondary" @click="showEditTrxModal = false" />
|
||||||
|
<Button label="ذخیره تغییرات" icon="pi pi-check" :loading="savingTrx" @click="submitEditTransaction" />
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, computed, watch } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
import { paymentApi } from '@/api/paymentApi';
|
||||||
|
import { usePersianDate } from '@/composables/usePersianDate';
|
||||||
|
import { useToast } from '@/composables/useToast';
|
||||||
|
import { getPayableAmount } from '@/utils/paymentAmount';
|
||||||
|
import Dialog from 'primevue/dialog';
|
||||||
|
import Button from 'primevue/button';
|
||||||
|
import InputText from 'primevue/inputtext';
|
||||||
|
import InputNumber from 'primevue/inputnumber';
|
||||||
|
import InputGroup from 'primevue/inputgroup';
|
||||||
|
import InputGroupAddon from 'primevue/inputgroupaddon';
|
||||||
|
import Dropdown from 'primevue/select';
|
||||||
|
import Textarea from 'primevue/textarea';
|
||||||
|
import Tag from 'primevue/tag';
|
||||||
|
import DataTable from 'primevue/datatable';
|
||||||
|
import Column from 'primevue/column';
|
||||||
|
import DatePicker from 'vue3-persian-datetime-picker';
|
||||||
|
import StatusTag from '@/components/common/StatusTag.vue';
|
||||||
|
import NotifyChannelsField from '@/components/common/NotifyChannelsField.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
visible: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
paymentId: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:visible', 'updated']);
|
||||||
|
const router = useRouter();
|
||||||
|
const { showSuccess, showError } = useToast();
|
||||||
|
const { toPersianDigits, formatJalali, toJalaliPickerValue, toGregorianIso, getTodayJalali } = usePersianDate();
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const savingPayment = ref(false);
|
||||||
|
const savingTrx = ref(false);
|
||||||
|
const payment = ref(null);
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
amount: 0,
|
||||||
|
discount: 0,
|
||||||
|
dueDate: '',
|
||||||
|
status: 'pending',
|
||||||
|
type: 'regular',
|
||||||
|
notes: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
const showAddTrxModal = ref(false);
|
||||||
|
const trxAddForm = reactive({
|
||||||
|
amount: null,
|
||||||
|
status: 'paid',
|
||||||
|
method: 'card',
|
||||||
|
dueDate: '',
|
||||||
|
date: '',
|
||||||
|
receiptNumber: '',
|
||||||
|
notes: ''
|
||||||
|
});
|
||||||
|
const trxAddNotify = reactive({ sms: true, email: true, bot: true });
|
||||||
|
|
||||||
|
const showEditTrxModal = ref(false);
|
||||||
|
const editingTrx = ref(null);
|
||||||
|
const trxEditForm = reactive({
|
||||||
|
amount: 0,
|
||||||
|
status: 'paid',
|
||||||
|
method: 'card',
|
||||||
|
dueDate: '',
|
||||||
|
date: '',
|
||||||
|
receiptNumber: '',
|
||||||
|
notes: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
const paymentStatusOptions = [
|
||||||
|
{ label: 'در انتظار پرداخت', value: 'pending' },
|
||||||
|
{ label: 'پیش پرداخت', value: 'partial' },
|
||||||
|
{ label: 'پرداختشده (تسویه)', value: 'paid' },
|
||||||
|
{ label: 'معوق', value: 'overdue' },
|
||||||
|
{ label: 'لغو شده', value: 'cancelled' },
|
||||||
|
{ label: 'مسترد شده', value: 'reverted' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const paymentTypeOptions = [
|
||||||
|
{ label: 'عادی (کلاس)', value: 'regular' },
|
||||||
|
{ label: 'لیست انتظار', value: 'waiting_list' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const methodOptions = [
|
||||||
|
{ label: 'کارت به کارت', value: 'card' },
|
||||||
|
{ label: 'درگاه آنلاین', value: 'online' },
|
||||||
|
{ label: 'نقدی', value: 'cash' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const trxStatusOptions = [
|
||||||
|
{ label: 'پرداخت شده (تسویه)', value: 'paid' },
|
||||||
|
{ label: 'در انتظار پرداخت', value: 'pending' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const trxFullStatusOptions = [
|
||||||
|
{ label: 'پرداخت شده', value: 'paid' },
|
||||||
|
{ label: 'در انتظار پرداخت', value: 'pending' },
|
||||||
|
{ label: 'لغوشده', value: 'cancelled' },
|
||||||
|
{ label: 'مسترد شده', value: 'reverted' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const dialogTitle = computed(() => {
|
||||||
|
if (!payment.value) return 'ویرایش سریع صورتحساب';
|
||||||
|
const name = payment.value.user?.name || 'کاربر';
|
||||||
|
const code = payment.value.uniqueCode || payment.value._id;
|
||||||
|
return `ویرایش سریع صورتحساب: ${name} (${code})`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const payableAmount = computed(() => {
|
||||||
|
if (!payment.value) return 0;
|
||||||
|
return getPayableAmount(payment.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
const remainingAmount = computed(() => {
|
||||||
|
if (!payment.value) return 0;
|
||||||
|
const paid = payment.value.paidAmount || 0;
|
||||||
|
return Math.max(0, payableAmount.value - paid);
|
||||||
|
});
|
||||||
|
|
||||||
|
const computedFormPayable = computed(() => {
|
||||||
|
const amt = Number(form.amount) || 0;
|
||||||
|
const disc = Number(form.discount) || 0;
|
||||||
|
return Math.max(0, amt - Math.min(disc, amt));
|
||||||
|
});
|
||||||
|
|
||||||
|
const isTransactionCancelled = (trx) => {
|
||||||
|
const s = String(trx.status || '').toLowerCase();
|
||||||
|
return s === 'cancelled' || s === 'reverted';
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchPaymentDetails = async () => {
|
||||||
|
if (!props.paymentId) return;
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const res = await paymentApi.getOne(props.paymentId);
|
||||||
|
const data = res.data || res;
|
||||||
|
payment.value = data;
|
||||||
|
Object.assign(form, {
|
||||||
|
amount: data.amount || 0,
|
||||||
|
discount: data.discount || 0,
|
||||||
|
dueDate: toJalaliPickerValue(data.dueDate),
|
||||||
|
status: data.status || 'pending',
|
||||||
|
type: data.type || 'regular',
|
||||||
|
notes: data.notes || ''
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
showError(err);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSavePayment = async () => {
|
||||||
|
if (!props.paymentId) return;
|
||||||
|
savingPayment.value = true;
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
amount: form.amount,
|
||||||
|
discount: form.discount,
|
||||||
|
dueDate: toGregorianIso(form.dueDate),
|
||||||
|
status: form.status,
|
||||||
|
type: form.type,
|
||||||
|
notes: form.notes
|
||||||
|
};
|
||||||
|
const res = await paymentApi.update(props.paymentId, payload);
|
||||||
|
const updated = res.data || res;
|
||||||
|
payment.value = updated;
|
||||||
|
showSuccess('مشخصات صورتحساب با موفقیت ذخیره شد');
|
||||||
|
emit('updated', updated);
|
||||||
|
} catch (err) {
|
||||||
|
showError(err);
|
||||||
|
} finally {
|
||||||
|
savingPayment.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openAddTransactionModal = () => {
|
||||||
|
Object.assign(trxAddForm, {
|
||||||
|
amount: remainingAmount.value > 0 ? remainingAmount.value : null,
|
||||||
|
status: 'paid',
|
||||||
|
method: 'card',
|
||||||
|
dueDate: getTodayJalali(),
|
||||||
|
date: getTodayJalali(),
|
||||||
|
receiptNumber: '',
|
||||||
|
notes: ''
|
||||||
|
});
|
||||||
|
showAddTrxModal.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitAddTransaction = async () => {
|
||||||
|
if (!trxAddForm.amount || Number(trxAddForm.amount) <= 0) {
|
||||||
|
showError('مبلغ تراکنش باید بیشتر از صفر باشد');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
savingTrx.value = true;
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
amount: trxAddForm.amount,
|
||||||
|
status: trxAddForm.status,
|
||||||
|
method: trxAddForm.status === 'paid' ? trxAddForm.method : undefined,
|
||||||
|
receiptNumber: trxAddForm.status === 'paid' ? trxAddForm.receiptNumber : undefined,
|
||||||
|
dueDate: toGregorianIso(trxAddForm.dueDate) || new Date(),
|
||||||
|
date: trxAddForm.status === 'paid' ? (toGregorianIso(trxAddForm.date) || new Date()) : undefined,
|
||||||
|
notes: trxAddForm.notes,
|
||||||
|
notify: { ...trxAddNotify }
|
||||||
|
};
|
||||||
|
const res = await paymentApi.recordTransaction(props.paymentId, payload);
|
||||||
|
const updated = res.data || res;
|
||||||
|
payment.value = updated;
|
||||||
|
showAddTrxModal.value = false;
|
||||||
|
showSuccess('تراکنش با موفقیت ثبت شد');
|
||||||
|
emit('updated', updated);
|
||||||
|
} catch (err) {
|
||||||
|
showError(err);
|
||||||
|
} finally {
|
||||||
|
savingTrx.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEditTransactionModal = (trx) => {
|
||||||
|
editingTrx.value = trx;
|
||||||
|
Object.assign(trxEditForm, {
|
||||||
|
amount: trx.amount || 0,
|
||||||
|
status: trx.status || (trx.date ? 'paid' : 'pending'),
|
||||||
|
method: trx.method || 'card',
|
||||||
|
dueDate: toJalaliPickerValue(trx.dueDate),
|
||||||
|
date: toJalaliPickerValue(trx.date),
|
||||||
|
receiptNumber: trx.receiptNumber || '',
|
||||||
|
notes: trx.notes || ''
|
||||||
|
});
|
||||||
|
showEditTrxModal.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitEditTransaction = async () => {
|
||||||
|
if (!editingTrx.value?._id) return;
|
||||||
|
savingTrx.value = true;
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
amount: trxEditForm.amount,
|
||||||
|
status: trxEditForm.status,
|
||||||
|
method: trxEditForm.status === 'paid' ? trxEditForm.method : undefined,
|
||||||
|
receiptNumber: trxEditForm.status === 'paid' ? trxEditForm.receiptNumber : undefined,
|
||||||
|
dueDate: toGregorianIso(trxEditForm.dueDate),
|
||||||
|
date: trxEditForm.status === 'paid' ? toGregorianIso(trxEditForm.date) : undefined,
|
||||||
|
notes: trxEditForm.notes
|
||||||
|
};
|
||||||
|
const res = await paymentApi.updateTransaction(editingTrx.value._id, payload);
|
||||||
|
const updated = res.data || res;
|
||||||
|
payment.value = updated;
|
||||||
|
showEditTrxModal.value = false;
|
||||||
|
showSuccess('تراکنش با موفقیت ویرایش شد');
|
||||||
|
emit('updated', updated);
|
||||||
|
} catch (err) {
|
||||||
|
showError(err);
|
||||||
|
} finally {
|
||||||
|
savingTrx.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancelTransaction = async (trx) => {
|
||||||
|
if (!trx._id) return;
|
||||||
|
try {
|
||||||
|
const res = await paymentApi.cancelTransaction(trx._id);
|
||||||
|
const updated = res.data || res;
|
||||||
|
payment.value = updated;
|
||||||
|
showSuccess('تراکنش با موفقیت لغو شد');
|
||||||
|
emit('updated', updated);
|
||||||
|
} catch (err) {
|
||||||
|
showError(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRevertTransaction = async (trx) => {
|
||||||
|
if (!trx._id) return;
|
||||||
|
try {
|
||||||
|
const res = await paymentApi.revertTransaction(trx._id);
|
||||||
|
const updated = res.data || res;
|
||||||
|
payment.value = updated;
|
||||||
|
showSuccess('تراکنش با موفقیت مسترد شد');
|
||||||
|
emit('updated', updated);
|
||||||
|
} catch (err) {
|
||||||
|
showError(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteTransaction = async (trx) => {
|
||||||
|
if (!trx._id) return;
|
||||||
|
try {
|
||||||
|
const res = await paymentApi.deleteTransaction(trx._id);
|
||||||
|
const updated = res.data || res;
|
||||||
|
payment.value = updated;
|
||||||
|
showSuccess('تراکنش حذف شد');
|
||||||
|
emit('updated', updated);
|
||||||
|
} catch (err) {
|
||||||
|
showError(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const navigateToFullPage = () => {
|
||||||
|
if (payment.value?._id) {
|
||||||
|
emit('update:visible', false);
|
||||||
|
router.push(`/payments/view/${payment.value._id}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
payment.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.visible, props.paymentId],
|
||||||
|
([newVisible, newId]) => {
|
||||||
|
if (newVisible && newId) {
|
||||||
|
fetchPaymentDetails();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.quick-edit-content {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
<!-- /src/components/professors/AddProfessorFromUserDialog.vue -->
|
||||||
|
<template>
|
||||||
|
<Dialog
|
||||||
|
v-model:visible="visible"
|
||||||
|
header="افزودن استاد از بین دانشجویان / کاربران"
|
||||||
|
modal
|
||||||
|
:style="{ width: '600px', maxWidth: '95vw' }"
|
||||||
|
:closable="!isSubmitting"
|
||||||
|
>
|
||||||
|
<div class="flex flex-column gap-3 py-2">
|
||||||
|
<Message severity="info" :closable="false" class="m-0 text-xs">
|
||||||
|
با تبدیل این دانشجو به استاد، دسترسیها و نقش سیستمی وی به <b>«استاد»</b> ارتقا مییابد و یک پروفایل استادی جدید برای ایشان ایجاد میشود. نام کاربری و رمز عبور قبلی کاربر حفظ خواهد شد.
|
||||||
|
</Message>
|
||||||
|
|
||||||
|
<!-- 1. User Selection -->
|
||||||
|
<div class="flex flex-column gap-1">
|
||||||
|
<label class="font-semibold text-sm">انتخاب دانشجو / کاربر *</label>
|
||||||
|
<Dropdown
|
||||||
|
v-model="selectedUserId"
|
||||||
|
:options="userOptions"
|
||||||
|
optionLabel="label"
|
||||||
|
optionValue="value"
|
||||||
|
placeholder="دانشجو را از لیست انتخاب کنید یا نام/کد ملی را جستجو کنید"
|
||||||
|
filter
|
||||||
|
:filterFields="['name', 'nationalId', 'phoneNumber', 'username']"
|
||||||
|
:loading="isLoadingUsers"
|
||||||
|
class="w-full text-sm"
|
||||||
|
@change="onUserSelected"
|
||||||
|
>
|
||||||
|
<template #option="{ option }">
|
||||||
|
<div class="flex flex-column gap-1 py-1">
|
||||||
|
<div class="flex align-items-center justify-content-between">
|
||||||
|
<span class="font-bold text-sm text-color">{{ option.name }}</span>
|
||||||
|
<Tag :value="option.roleName || 'دانشجو'" severity="secondary" class="text-xs" />
|
||||||
|
</div>
|
||||||
|
<div class="flex align-items-center gap-3 text-xs text-muted">
|
||||||
|
<span v-if="option.nationalId">کد ملی: {{ toPersianDigits(option.nationalId) }}</span>
|
||||||
|
<span v-if="option.phoneNumber">همراه: {{ toPersianDigits(option.phoneNumber) }}</span>
|
||||||
|
<span v-if="option.username">(@{{ option.username }})</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 2. Selected User Details Preview -->
|
||||||
|
<div v-if="selectedUser" class="surface-ground p-3 border-round border-1 border-color flex flex-column gap-2 text-xs">
|
||||||
|
<div class="flex align-items-center justify-content-between border-bottom-1 border-color pb-2">
|
||||||
|
<div class="flex align-items-center gap-2">
|
||||||
|
<i class="pi pi-user text-primary font-bold"></i>
|
||||||
|
<span class="font-bold text-sm text-color">{{ selectedUser.name }}</span>
|
||||||
|
</div>
|
||||||
|
<Tag :value="`نقش فعلی: ${selectedUser.role?.name || 'کاربر / دانشجو'}`" severity="info" />
|
||||||
|
</div>
|
||||||
|
<div class="grid pt-1 m-0">
|
||||||
|
<div class="col-6 p-1"><span class="text-muted">کد ملی: </span><span class="font-bold" dir="ltr">{{ toPersianDigits(selectedUser.nationalIdCode || selectedUser.nationalId || '-') }}</span></div>
|
||||||
|
<div class="col-6 p-1"><span class="text-muted">شماره همراه: </span><span class="font-bold" dir="ltr">{{ toPersianDigits(selectedUser.phoneNumber || selectedUser.phone || '-') }}</span></div>
|
||||||
|
<div class="col-6 p-1" v-if="selectedUser.email"><span class="text-muted">ایمیل: </span><span>{{ selectedUser.email }}</span></div>
|
||||||
|
<div class="col-6 p-1" v-if="selectedUser.education"><span class="text-muted">تحصیلات: </span><span>{{ selectedUser.education }}</span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 3. Form fields for Professor details -->
|
||||||
|
<div class="grid mt-2" v-if="selectedUser">
|
||||||
|
<div class="col-12 sm:col-6 flex flex-column gap-1">
|
||||||
|
<label class="font-semibold text-sm">نام استاد *</label>
|
||||||
|
<InputText v-model.trim="form.name" class="w-full text-sm" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 sm:col-6 flex flex-column gap-1">
|
||||||
|
<label class="font-semibold text-sm">نام خانوادگی استاد *</label>
|
||||||
|
<InputText v-model.trim="form.surname" class="w-full text-sm" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 sm:col-6 flex flex-column gap-1">
|
||||||
|
<label class="font-semibold text-sm">شماره کارت جهت تسویه</label>
|
||||||
|
<InputText v-model.trim="form.cardNumber" class="w-full text-sm" dir="ltr" placeholder="۱۶ رقمی" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 sm:col-6 flex flex-column gap-1">
|
||||||
|
<label class="font-semibold text-sm">شماره شبا (IBAN)</label>
|
||||||
|
<InputText v-model.trim="form.shabaNumber" class="w-full text-sm" dir="ltr" placeholder="IR..." />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 flex flex-column gap-1">
|
||||||
|
<label class="font-semibold text-sm">حوزههای تخصص و مهارتها</label>
|
||||||
|
<Chips v-model="form.expertise" separator="," placeholder="تخصصها را وارد کرده و Enter بزنید" class="w-full text-sm" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 flex flex-column gap-1">
|
||||||
|
<label class="font-semibold text-sm">رزومه و بیوگرافی استاد</label>
|
||||||
|
<Textarea v-model="form.bio" rows="2" class="w-full text-sm" placeholder="سوابق تدریس، مهارتها و توضیحات تکمیلی..." />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex justify-content-end gap-2">
|
||||||
|
<Button label="انصراف" text severity="secondary" :disabled="isSubmitting" @click="visible = false" />
|
||||||
|
<Button
|
||||||
|
label="ارتقا به استاد و ثبت"
|
||||||
|
icon="pi pi-user-plus"
|
||||||
|
severity="success"
|
||||||
|
:loading="isSubmitting"
|
||||||
|
:disabled="!selectedUser"
|
||||||
|
@click="handleSubmit"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, computed, watch } from 'vue';
|
||||||
|
import { professorApi } from '@/api/professorApi';
|
||||||
|
import { userApi } from '@/api/userApi';
|
||||||
|
import { usePersianDate } from '@/composables/usePersianDate';
|
||||||
|
import { useToast } from '@/composables/useToast';
|
||||||
|
import Dialog from 'primevue/dialog';
|
||||||
|
import Dropdown from 'primevue/select';
|
||||||
|
import InputText from 'primevue/inputtext';
|
||||||
|
import Textarea from 'primevue/textarea';
|
||||||
|
import Chips from 'primevue/chips';
|
||||||
|
import Button from 'primevue/button';
|
||||||
|
import Tag from 'primevue/tag';
|
||||||
|
import Message from 'primevue/message';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: { type: Boolean, default: false },
|
||||||
|
preselectedUserId: { type: String, default: null }
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['update:modelValue', 'saved']);
|
||||||
|
|
||||||
|
const { toPersianDigits } = usePersianDate();
|
||||||
|
const { showSuccess, showError } = useToast();
|
||||||
|
|
||||||
|
const visible = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: (val) => emit('update:modelValue', val)
|
||||||
|
});
|
||||||
|
|
||||||
|
const rawUsers = ref([]);
|
||||||
|
const isLoadingUsers = ref(false);
|
||||||
|
const selectedUserId = ref(null);
|
||||||
|
const selectedUser = ref(null);
|
||||||
|
const isSubmitting = ref(false);
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
name: '',
|
||||||
|
surname: '',
|
||||||
|
cardNumber: '',
|
||||||
|
shabaNumber: '',
|
||||||
|
expertise: [],
|
||||||
|
bio: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
const userOptions = computed(() => {
|
||||||
|
return rawUsers.value.map((u) => ({
|
||||||
|
label: `${u.name || ''} - ${u.phoneNumber || ''}`,
|
||||||
|
value: u._id || u.id,
|
||||||
|
name: u.name,
|
||||||
|
nationalId: u.nationalIdCode || u.nationalId,
|
||||||
|
phoneNumber: u.phoneNumber || u.phone,
|
||||||
|
username: u.username,
|
||||||
|
roleName: u.role?.name,
|
||||||
|
raw: u
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
const fetchUsers = async () => {
|
||||||
|
isLoadingUsers.value = true;
|
||||||
|
try {
|
||||||
|
const res = await userApi.getAll({ limit: 200 });
|
||||||
|
const data = res.data || res;
|
||||||
|
rawUsers.value = data.items || data.users || data || [];
|
||||||
|
} catch (err) {
|
||||||
|
showError(err);
|
||||||
|
} finally {
|
||||||
|
isLoadingUsers.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onUserSelected = () => {
|
||||||
|
const found = rawUsers.value.find((u) => String(u._id || u.id) === String(selectedUserId.value));
|
||||||
|
selectedUser.value = found || null;
|
||||||
|
if (found) {
|
||||||
|
const rawName = String(found.name || '').trim();
|
||||||
|
const parts = rawName.split(/\s+/);
|
||||||
|
if (parts.length > 1) {
|
||||||
|
form.name = parts[0];
|
||||||
|
form.surname = parts.slice(1).join(' ');
|
||||||
|
} else {
|
||||||
|
form.name = rawName;
|
||||||
|
form.surname = '';
|
||||||
|
}
|
||||||
|
form.cardNumber = found.cardNumber || '';
|
||||||
|
form.shabaNumber = found.shabaNumber || found.iban || '';
|
||||||
|
form.expertise = [];
|
||||||
|
form.bio = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!selectedUserId.value) {
|
||||||
|
showError('لطفاً ابتدا یک دانشجو را انتخاب کنید');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!form.name || !form.surname) {
|
||||||
|
showError('نام و نام خانوادگی استاد الزامی است');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isSubmitting.value = true;
|
||||||
|
try {
|
||||||
|
await professorApi.createFromUser({
|
||||||
|
userId: selectedUserId.value,
|
||||||
|
name: form.name,
|
||||||
|
surname: form.surname,
|
||||||
|
cardNumber: form.cardNumber || undefined,
|
||||||
|
shabaNumber: form.shabaNumber || undefined,
|
||||||
|
expertise: form.expertise,
|
||||||
|
bio: form.bio || undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
showSuccess('دانشجو با موفقیت به نقش استاد ارتقا یافت و در لیست اساتید ثبت شد');
|
||||||
|
visible.value = false;
|
||||||
|
emit('saved');
|
||||||
|
} catch (err) {
|
||||||
|
showError(err);
|
||||||
|
} finally {
|
||||||
|
isSubmitting.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(val) => {
|
||||||
|
if (val) {
|
||||||
|
fetchUsers();
|
||||||
|
if (props.preselectedUserId) {
|
||||||
|
selectedUserId.value = props.preselectedUserId;
|
||||||
|
onUserSelected();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
selectedUserId.value = null;
|
||||||
|
selectedUser.value = null;
|
||||||
|
form.name = '';
|
||||||
|
form.surname = '';
|
||||||
|
form.cardNumber = '';
|
||||||
|
form.shabaNumber = '';
|
||||||
|
form.expertise = [];
|
||||||
|
form.bio = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
<!-- /src/components/users/charts/StudentAttendanceChart.vue -->
|
||||||
|
<template>
|
||||||
|
<div class="student-attendance-chart-wrap flex flex-column align-items-center justify-content-center">
|
||||||
|
<div v-if="hasData" class="chart-container" :style="{ height: height + 'px' }">
|
||||||
|
<Doughnut :data="chartData" :options="chartOptions" />
|
||||||
|
</div>
|
||||||
|
<div v-else class="flex flex-column align-items-center justify-content-center h-full p-4 text-muted text-sm gap-2">
|
||||||
|
<i class="pi pi-calendar-times text-2xl text-400"></i>
|
||||||
|
<span>اطلاعات حضور و غیابی برای این دانشجو ثبت نشده است</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { Doughnut } from 'vue-chartjs';
|
||||||
|
import { usePersianDate } from '@/composables/usePersianDate';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
summary: {
|
||||||
|
type: Object,
|
||||||
|
default: () => ({
|
||||||
|
present: 0,
|
||||||
|
absent: 0,
|
||||||
|
late: 0,
|
||||||
|
excused: 0,
|
||||||
|
total: 0,
|
||||||
|
attendanceRate: 0
|
||||||
|
})
|
||||||
|
},
|
||||||
|
height: { type: Number, default: 220 }
|
||||||
|
});
|
||||||
|
|
||||||
|
const { toPersianDigits } = usePersianDate();
|
||||||
|
|
||||||
|
const hasData = computed(() => {
|
||||||
|
const s = props.summary || {};
|
||||||
|
return (s.present || 0) + (s.absent || 0) + (s.late || 0) + (s.excused || 0) > 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
const chartData = computed(() => {
|
||||||
|
const s = props.summary || {};
|
||||||
|
return {
|
||||||
|
labels: ['حاضر', 'تاخیر', 'موجه', 'غایب'],
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
data: [s.present || 0, s.late || 0, s.excused || 0, s.absent || 0],
|
||||||
|
backgroundColor: ['#10b981', '#f59e0b', '#3b82f6', '#ef4444'],
|
||||||
|
borderWidth: 0,
|
||||||
|
hoverOffset: 6
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const chartOptions = computed(() => ({
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
cutout: '70%',
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
position: 'bottom',
|
||||||
|
labels: {
|
||||||
|
usePointStyle: true,
|
||||||
|
pointStyle: 'circle',
|
||||||
|
boxWidth: 8,
|
||||||
|
boxHeight: 8,
|
||||||
|
font: { family: "'Vazirmatn', sans-serif", size: 11 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
rtl: true,
|
||||||
|
textDirection: 'rtl',
|
||||||
|
callbacks: {
|
||||||
|
label: (ctx) => `${ctx.label}: ${toPersianDigits(ctx.parsed)} جلسه`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.student-attendance-chart-wrap {
|
||||||
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.chart-container {
|
||||||
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
<!-- /src/components/users/charts/StudentPaymentStatusChart.vue -->
|
||||||
|
<template>
|
||||||
|
<div class="student-payment-status-chart-wrap flex flex-column align-items-center justify-content-center">
|
||||||
|
<div v-if="hasData" class="chart-container" :style="{ height: height + 'px' }">
|
||||||
|
<Doughnut :data="chartData" :options="chartOptions" />
|
||||||
|
</div>
|
||||||
|
<div v-else class="flex flex-column align-items-center justify-content-center h-full p-4 text-muted text-sm gap-2">
|
||||||
|
<i class="pi pi-credit-card text-2xl text-400"></i>
|
||||||
|
<span>صورتحساب یا پرداختی برای این دانشجو یافت نشد</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { Doughnut } from 'vue-chartjs';
|
||||||
|
import { usePersianDate } from '@/composables/usePersianDate';
|
||||||
|
import { STATUS_COLORS, STATUS_LABELS_FA } from '@/utils/chartTheme';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
breakdown: { type: Array, default: () => [] }, // [{ status, count, amount }]
|
||||||
|
height: { type: Number, default: 220 }
|
||||||
|
});
|
||||||
|
|
||||||
|
const { toPersianDigits } = usePersianDate();
|
||||||
|
const formatToman = (v) => `${toPersianDigits(Math.round(v || 0).toLocaleString())} تومان`;
|
||||||
|
|
||||||
|
const hasData = computed(() => {
|
||||||
|
return (props.breakdown || []).some((b) => b.amount > 0 || b.count > 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
const chartData = computed(() => {
|
||||||
|
const list = (props.breakdown || []).filter((b) => b.amount > 0 || b.count > 0);
|
||||||
|
return {
|
||||||
|
labels: list.map((b) => STATUS_LABELS_FA[b.status] || b.status),
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
data: list.map((b) => b.amount),
|
||||||
|
backgroundColor: list.map((b) => STATUS_COLORS[b.status] || '#94a3b8'),
|
||||||
|
borderWidth: 0,
|
||||||
|
hoverOffset: 6
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const chartOptions = computed(() => ({
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
cutout: '70%',
|
||||||
|
plugins: {
|
||||||
|
legend: {
|
||||||
|
position: 'bottom',
|
||||||
|
labels: {
|
||||||
|
usePointStyle: true,
|
||||||
|
pointStyle: 'circle',
|
||||||
|
boxWidth: 8,
|
||||||
|
boxHeight: 8,
|
||||||
|
font: { family: "'Vazirmatn', sans-serif", size: 11 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
rtl: true,
|
||||||
|
textDirection: 'rtl',
|
||||||
|
callbacks: {
|
||||||
|
label: (ctx) => {
|
||||||
|
const item = (props.breakdown || [])[ctx.dataIndex];
|
||||||
|
const count = item ? toPersianDigits(item.count) : '';
|
||||||
|
return `${ctx.label}: ${formatToman(ctx.parsed)} (${count} فاکتور)`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.student-payment-status-chart-wrap {
|
||||||
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.chart-container {
|
||||||
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
<!-- /src/components/users/charts/StudentPaymentTimelineChart.vue -->
|
||||||
|
<template>
|
||||||
|
<div class="student-payment-timeline-chart-wrap">
|
||||||
|
<div v-if="hasData" class="chart-container" :style="{ height: height + 'px' }">
|
||||||
|
<Bar :data="chartData" :options="chartOptions" />
|
||||||
|
</div>
|
||||||
|
<div v-else class="flex flex-column align-items-center justify-content-center h-full p-4 text-muted text-sm gap-2">
|
||||||
|
<i class="pi pi-chart-bar text-2xl text-400"></i>
|
||||||
|
<span>تراکنش مالی ثبت شدهای در طول زمان یافت نشد</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { Bar } from 'vue-chartjs';
|
||||||
|
import { usePersianDate } from '@/composables/usePersianDate';
|
||||||
|
import { baseGridOptions } from '@/utils/chartTheme';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
timeline: { type: Array, default: () => [] }, // [{ month: 'YYYY-MM', amount: 10000, count: 2 }]
|
||||||
|
height: { type: Number, default: 220 }
|
||||||
|
});
|
||||||
|
|
||||||
|
const { toPersianDigits } = usePersianDate();
|
||||||
|
const formatToman = (v) => `${toPersianDigits(Math.round(v || 0).toLocaleString())} تومان`;
|
||||||
|
|
||||||
|
const hasData = computed(() => {
|
||||||
|
return (props.timeline || []).some((t) => t.amount > 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
const chartData = computed(() => {
|
||||||
|
const list = props.timeline || [];
|
||||||
|
return {
|
||||||
|
labels: list.map((t) => t.month),
|
||||||
|
datasets: [
|
||||||
|
{
|
||||||
|
label: 'مبلغ پرداختی (تومان)',
|
||||||
|
data: list.map((t) => t.amount),
|
||||||
|
backgroundColor: 'rgba(16, 185, 129, 0.75)',
|
||||||
|
hoverBackgroundColor: '#10b981',
|
||||||
|
borderRadius: 6,
|
||||||
|
borderSkipped: false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const chartOptions = computed(() => ({
|
||||||
|
responsive: true,
|
||||||
|
maintainAspectRatio: false,
|
||||||
|
scales: {
|
||||||
|
x: baseGridOptions(),
|
||||||
|
y: {
|
||||||
|
...baseGridOptions(),
|
||||||
|
ticks: {
|
||||||
|
...baseGridOptions().ticks,
|
||||||
|
callback: (value) => formatToman(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
plugins: {
|
||||||
|
legend: { display: false },
|
||||||
|
tooltip: {
|
||||||
|
rtl: true,
|
||||||
|
textDirection: 'rtl',
|
||||||
|
callbacks: {
|
||||||
|
label: (ctx) => {
|
||||||
|
const item = (props.timeline || [])[ctx.dataIndex];
|
||||||
|
const count = item ? toPersianDigits(item.count) : '0';
|
||||||
|
return `پرداختی: ${formatToman(ctx.parsed.y)} (${count} تراکنش)`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.student-payment-timeline-chart-wrap {
|
||||||
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.chart-container {
|
||||||
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -19,23 +19,71 @@ export function usePersianDate() {
|
|||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const parseToMoment = (value) => {
|
||||||
|
if (!value) return null;
|
||||||
|
if (value instanceof Date) {
|
||||||
|
if (Number.isNaN(value.getTime())) return null;
|
||||||
|
return moment.utc(value.toISOString());
|
||||||
|
}
|
||||||
|
const latin = toLatinDigits(String(value)).trim();
|
||||||
|
if (!latin) return null;
|
||||||
|
|
||||||
|
if (/^\d{4}-\d{2}-\d{2}T/.test(latin)) {
|
||||||
|
const m = moment.utc(latin);
|
||||||
|
return m.isValid() ? m : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = latin.match(/^(\d{3,4})[./\-](\d{1,2})[./\-](\d{1,2})/);
|
||||||
|
if (match) {
|
||||||
|
const year = parseInt(match[1], 10);
|
||||||
|
const month = parseInt(match[2], 10);
|
||||||
|
const day = parseInt(match[3], 10);
|
||||||
|
if (year >= 1200 && year <= 1599) {
|
||||||
|
const normalized = `${year}/${String(month).padStart(2, '0')}/${String(day).padStart(2, '0')}`;
|
||||||
|
const m = moment(normalized, 'jYYYY/jMM/jDD');
|
||||||
|
return m.isValid() ? m : null;
|
||||||
|
}
|
||||||
|
if (year >= 1900 && year <= 2200) {
|
||||||
|
const normalized = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||||
|
const m = moment.utc(normalized, 'YYYY-MM-DD');
|
||||||
|
return m.isValid() ? m : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const m = moment.utc(latin);
|
||||||
|
return m.isValid() ? m : null;
|
||||||
|
};
|
||||||
|
|
||||||
const formatJalali = (date, formatStr = 'jYYYY/jMM/jDD') => {
|
const formatJalali = (date, formatStr = 'jYYYY/jMM/jDD') => {
|
||||||
if (!date) return '-';
|
if (!date) return '-';
|
||||||
try {
|
try {
|
||||||
return toPersianDigits(moment(date).locale('fa').format(formatStr));
|
const m = parseToMoment(date);
|
||||||
|
if (!m || !m.isValid()) return '-';
|
||||||
|
return toPersianDigits(m.locale('fa').format(formatStr));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return '-';
|
return '-';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatJalaliWithTime = (date) => {
|
const formatJalaliWithTime = (date) => {
|
||||||
return formatJalali(date, 'jYYYY/jMM/jDD - HH:mm');
|
if (!date) return '-';
|
||||||
|
try {
|
||||||
|
const latin = toLatinDigits(String(date)).trim();
|
||||||
|
if (/^\d{4}-\d{2}-\d{2}T/.test(latin) || date instanceof Date) {
|
||||||
|
return toPersianDigits(moment(date).locale('fa').format('jYYYY/jMM/jDD - HH:mm'));
|
||||||
|
}
|
||||||
|
return formatJalali(date, 'jYYYY/jMM/jDD - HH:mm');
|
||||||
|
} catch (e) {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const toJalaliPickerValue = (value) => {
|
const toJalaliPickerValue = (value) => {
|
||||||
if (!value) return '';
|
if (!value) return '';
|
||||||
try {
|
try {
|
||||||
return toLatinDigits(moment(value).locale('fa').format('jYYYY/jMM/jDD'));
|
const m = parseToMoment(value);
|
||||||
|
if (!m || !m.isValid()) return '';
|
||||||
|
return toLatinDigits(m.locale('fa').format('jYYYY/jMM/jDD'));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
@@ -43,11 +91,10 @@ export function usePersianDate() {
|
|||||||
|
|
||||||
const toGregorianIso = (jalaliValue) => {
|
const toGregorianIso = (jalaliValue) => {
|
||||||
if (!jalaliValue) return undefined;
|
if (!jalaliValue) return undefined;
|
||||||
if (jalaliValue instanceof Date) return jalaliValue.toISOString();
|
|
||||||
try {
|
try {
|
||||||
const latin = toLatinDigits(String(jalaliValue));
|
const m = parseToMoment(jalaliValue);
|
||||||
const m = moment(latin, 'jYYYY/jMM/jDD');
|
if (!m || !m.isValid()) return undefined;
|
||||||
return m.isValid() ? m.toDate().toISOString() : undefined;
|
return `${m.format('YYYY-MM-DD')}T00:00:00.000Z`;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@@ -66,6 +113,7 @@ export function usePersianDate() {
|
|||||||
return {
|
return {
|
||||||
toPersianDigits,
|
toPersianDigits,
|
||||||
toLatinDigits,
|
toLatinDigits,
|
||||||
|
parseToMoment,
|
||||||
formatJalali,
|
formatJalali,
|
||||||
formatJalaliWithTime,
|
formatJalaliWithTime,
|
||||||
toJalaliPickerValue,
|
toJalaliPickerValue,
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
// /src/composables/usePersianDate.test.js
|
||||||
|
import { describe, it } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { usePersianDate } from './usePersianDate.js';
|
||||||
|
|
||||||
|
describe('usePersianDate', () => {
|
||||||
|
const {
|
||||||
|
toPersianDigits,
|
||||||
|
toLatinDigits,
|
||||||
|
toGregorianIso,
|
||||||
|
toJalaliPickerValue,
|
||||||
|
formatJalali,
|
||||||
|
getTodayJalali
|
||||||
|
} = usePersianDate();
|
||||||
|
|
||||||
|
it('converts Persian digits and Latin digits back and forth', () => {
|
||||||
|
assert.equal(toPersianDigits('1405/05/26'), '۱۴۰۵/۰۵/۲۶');
|
||||||
|
assert.equal(toLatinDigits('۱۴۰۵/۰۵/۲۶'), '1405/05/26');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts Jalali date string to Gregorian ISO without shifting day', () => {
|
||||||
|
assert.equal(toGregorianIso('1405/05/26'), '2026-08-17T00:00:00.000Z');
|
||||||
|
assert.equal(toGregorianIso('1405-05-26'), '2026-08-17T00:00:00.000Z');
|
||||||
|
assert.equal(toGregorianIso('۱۴۰۵/۰۵/۲۶'), '2026-08-17T00:00:00.000Z');
|
||||||
|
assert.equal(toGregorianIso('2026-08-17'), '2026-08-17T00:00:00.000Z');
|
||||||
|
assert.equal(toGregorianIso('2026-08-17T00:00:00.000Z'), '2026-08-17T00:00:00.000Z');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts ISO or Gregorian date to Jalali picker value without 1-day offset', () => {
|
||||||
|
assert.equal(toJalaliPickerValue('2026-08-17T00:00:00.000Z'), '1405/05/26');
|
||||||
|
assert.equal(toJalaliPickerValue('2026-08-17'), '1405/05/26');
|
||||||
|
assert.equal(toJalaliPickerValue('1405/05/26'), '1405/05/26');
|
||||||
|
assert.equal(toJalaliPickerValue('۱۴۰۵/۰۵/۲۶'), '1405/05/26');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats Jalali date for display', () => {
|
||||||
|
assert.equal(formatJalali('2026-08-17T00:00:00.000Z'), '۱۴۰۵/۰۵/۲۶');
|
||||||
|
assert.equal(formatJalali('1405/05/26'), '۱۴۰۵/۰۵/۲۶');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -84,7 +84,17 @@ export const PERMISSIONS = {
|
|||||||
EXPENSES_UPDATE: 'expenses:update',
|
EXPENSES_UPDATE: 'expenses:update',
|
||||||
EXPENSES_DELETE: 'expenses:delete',
|
EXPENSES_DELETE: 'expenses:delete',
|
||||||
|
|
||||||
FINANCIAL_REPORTS_READ: 'financial_reports:read'
|
FINANCIAL_REPORTS_READ: 'financial_reports:read',
|
||||||
|
|
||||||
|
WAITLIST_CREATE: 'waitlist:create',
|
||||||
|
WAITLIST_READ: 'waitlist:read',
|
||||||
|
WAITLIST_UPDATE: 'waitlist:update',
|
||||||
|
WAITLIST_DELETE: 'waitlist:delete',
|
||||||
|
|
||||||
|
EMPLOYEE_TIMINGS_CREATE: 'employee_timings:create',
|
||||||
|
EMPLOYEE_TIMINGS_READ: 'employee_timings:read',
|
||||||
|
EMPLOYEE_TIMINGS_UPDATE: 'employee_timings:update',
|
||||||
|
EMPLOYEE_TIMINGS_DELETE: 'employee_timings:delete'
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PERMISSION_GROUPS = [
|
export const PERMISSION_GROUPS = [
|
||||||
@@ -101,6 +111,17 @@ export const PERMISSION_GROUPS = [
|
|||||||
{ key: PERMISSIONS.USERS_ENROLL, label: 'ثبتنام کاربر در دوره' }
|
{ key: PERMISSIONS.USERS_ENROLL, label: 'ثبتنام کاربر در دوره' }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'employee_timings',
|
||||||
|
label: 'تردد و ساعت کارمندان',
|
||||||
|
icon: 'pi pi-clock',
|
||||||
|
permissions: [
|
||||||
|
{ key: PERMISSIONS.EMPLOYEE_TIMINGS_CREATE, label: 'ثبت تردد جدید' },
|
||||||
|
{ key: PERMISSIONS.EMPLOYEE_TIMINGS_READ, label: 'مشاهده تردد کارمندان' },
|
||||||
|
{ key: PERMISSIONS.EMPLOYEE_TIMINGS_UPDATE, label: 'ویرایش تردد' },
|
||||||
|
{ key: PERMISSIONS.EMPLOYEE_TIMINGS_DELETE, label: 'حذف تردد' }
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'professors',
|
key: 'professors',
|
||||||
label: 'مدیریت اساتید',
|
label: 'مدیریت اساتید',
|
||||||
@@ -138,6 +159,17 @@ export const PERMISSION_GROUPS = [
|
|||||||
{ key: PERMISSIONS.CLASSES_REGISTER_USERS, label: 'ثبتنام کاربران در کلاس' }
|
{ key: PERMISSIONS.CLASSES_REGISTER_USERS, label: 'ثبتنام کاربران در کلاس' }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'waitlist',
|
||||||
|
label: 'لیست انتظار',
|
||||||
|
icon: 'pi pi-clock',
|
||||||
|
permissions: [
|
||||||
|
{ key: PERMISSIONS.WAITLIST_CREATE, label: 'افزودن به لیست انتظار' },
|
||||||
|
{ key: PERMISSIONS.WAITLIST_READ, label: 'مشاهده لیست انتظار' },
|
||||||
|
{ key: PERMISSIONS.WAITLIST_UPDATE, label: 'ویرایش / انتقال به کلاس' },
|
||||||
|
{ key: PERMISSIONS.WAITLIST_DELETE, label: 'حذف از لیست انتظار' }
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'sessions',
|
key: 'sessions',
|
||||||
label: 'جلسات آموزشی',
|
label: 'جلسات آموزشی',
|
||||||
|
|||||||
@@ -156,5 +156,15 @@
|
|||||||
"settings": {
|
"settings": {
|
||||||
"title": "تنظیمات سیستم",
|
"title": "تنظیمات سیستم",
|
||||||
"subtitle": "عملیات راهاندازی و پیکربندی اولیه پایگاه داده"
|
"subtitle": "عملیات راهاندازی و پیکربندی اولیه پایگاه داده"
|
||||||
|
},
|
||||||
|
"employeeTimings": {
|
||||||
|
"title": "ورود و خروج کارمندان",
|
||||||
|
"subtitle": "ثبت، ویرایش و مدیریت زمان تردد و ساعت کاری کارمندان",
|
||||||
|
"addTiming": "ثبت تردد جدید",
|
||||||
|
"editTiming": "ویرایش رکورد تردد"
|
||||||
|
},
|
||||||
|
"notificationTemplates": {
|
||||||
|
"title": "قالبهای اعلان",
|
||||||
|
"subtitle": "مدیریت متن، شناسهها و متغیرهای قالبهای پیامک، ایمیل و بازوی بله"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,6 +115,14 @@ export const routes = [
|
|||||||
component: () => import('@/views/classes/ClassDetailView.vue')
|
component: () => import('@/views/classes/ClassDetailView.vue')
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Waitlist
|
||||||
|
{
|
||||||
|
path: 'waitlist',
|
||||||
|
name: 'Waitlist',
|
||||||
|
component: () => import('@/views/waitlist/WaitlistListView.vue'),
|
||||||
|
meta: { title: 'لیست انتظار', permission: 'waitlist:read' }
|
||||||
|
},
|
||||||
|
|
||||||
// Sessions
|
// Sessions
|
||||||
{
|
{
|
||||||
path: 'sessions',
|
path: 'sessions',
|
||||||
@@ -233,6 +241,22 @@ export const routes = [
|
|||||||
meta: { permission: 'logs:read' }
|
meta: { permission: 'logs:read' }
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Employee timings
|
||||||
|
{
|
||||||
|
path: 'employee-timings',
|
||||||
|
name: 'EmployeeTimingList',
|
||||||
|
component: () => import('@/views/employeeTimings/EmployeeTimingListView.vue'),
|
||||||
|
meta: { title: 'ورود و خروج کارمندان', permission: 'employee_timings:read' }
|
||||||
|
},
|
||||||
|
|
||||||
|
// Notification templates
|
||||||
|
{
|
||||||
|
path: 'notification-templates',
|
||||||
|
name: 'NotificationTemplates',
|
||||||
|
component: () => import('@/views/notifications/NotificationTemplatesView.vue'),
|
||||||
|
meta: { title: 'قالبهای اعلان', superAdminOnly: true }
|
||||||
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
path: 'settings',
|
path: 'settings',
|
||||||
name: 'Settings',
|
name: 'Settings',
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
// /src/utils/chartTheme.js
|
||||||
|
import {
|
||||||
|
Chart as ChartJS,
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
BarElement,
|
||||||
|
LineElement,
|
||||||
|
PointElement,
|
||||||
|
ArcElement,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
Filler
|
||||||
|
} from 'chart.js';
|
||||||
|
|
||||||
|
ChartJS.register(
|
||||||
|
CategoryScale,
|
||||||
|
LinearScale,
|
||||||
|
BarElement,
|
||||||
|
LineElement,
|
||||||
|
PointElement,
|
||||||
|
ArcElement,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
Filler
|
||||||
|
);
|
||||||
|
|
||||||
|
ChartJS.defaults.font.family = "'Vazirmatn', sans-serif";
|
||||||
|
ChartJS.defaults.font.size = 12;
|
||||||
|
ChartJS.defaults.color = '#94a3b8';
|
||||||
|
|
||||||
|
/** Shared semantic palette — kept consistent with the stat-card icon colors used across the dashboard. */
|
||||||
|
export const CHART_COLORS = {
|
||||||
|
received: '#10b981',
|
||||||
|
receivedSoft: 'rgba(16, 185, 129, 0.15)',
|
||||||
|
sessionIncome: '#3b82f6',
|
||||||
|
sessionIncomeSoft: 'rgba(59, 130, 246, 0.15)',
|
||||||
|
outstanding: '#f59e0b',
|
||||||
|
outstandingSoft: 'rgba(245, 158, 11, 0.15)',
|
||||||
|
overdue: '#ef4444',
|
||||||
|
overdueSoft: 'rgba(239, 68, 68, 0.15)',
|
||||||
|
payout: '#8b5cf6',
|
||||||
|
payoutSoft: 'rgba(139, 92, 246, 0.15)',
|
||||||
|
expense: '#f43f5e',
|
||||||
|
expenseSoft: 'rgba(244, 63, 94, 0.15)',
|
||||||
|
netProfit: '#14b8a6',
|
||||||
|
netProfitSoft: 'rgba(20, 184, 166, 0.15)',
|
||||||
|
gridLine: 'rgba(148, 163, 184, 0.15)',
|
||||||
|
muted: '#94a3b8'
|
||||||
|
};
|
||||||
|
|
||||||
|
export const STATUS_COLORS = {
|
||||||
|
paid: '#10b981',
|
||||||
|
partial: '#3b82f6',
|
||||||
|
pending: '#f59e0b',
|
||||||
|
overdue: '#ef4444'
|
||||||
|
};
|
||||||
|
|
||||||
|
export const STATUS_LABELS_FA = {
|
||||||
|
paid: 'تسویهشده',
|
||||||
|
partial: 'پرداخت جزئی',
|
||||||
|
pending: 'در انتظار پرداخت',
|
||||||
|
overdue: 'معوق'
|
||||||
|
};
|
||||||
|
|
||||||
|
export const baseGridOptions = () => ({
|
||||||
|
grid: { color: CHART_COLORS.gridLine, drawBorder: false },
|
||||||
|
ticks: { color: CHART_COLORS.muted, font: { family: "'Vazirmatn', sans-serif" } }
|
||||||
|
});
|
||||||
|
|
||||||
|
export default ChartJS;
|
||||||
+50
-14
@@ -42,6 +42,30 @@ export function formatClassSchedule(cls) {
|
|||||||
return days || time || '';
|
return days || time || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parses any date format (Jalali string, ISO string, Date object, Persian/Latin digits) into a valid moment object
|
||||||
|
*/
|
||||||
|
export function parseDateSmart(input) {
|
||||||
|
if (!input) return null;
|
||||||
|
if (input instanceof Date) {
|
||||||
|
const m = moment(input);
|
||||||
|
return m.isValid() ? m : null;
|
||||||
|
}
|
||||||
|
const latin = String(input)
|
||||||
|
.replace(/[۰-۹]/g, (d) => '۰۱۲۳۴۵۶۷۸۹'.indexOf(d))
|
||||||
|
.replace(/[٠-٩]/g, (d) => '٠١٢٣٤٥٦٧٨٩'.indexOf(d))
|
||||||
|
.trim();
|
||||||
|
const isJalaliPattern = /^(1[34]\d{2})[/-](\d{1,2})[/-](\d{1,2})/.test(latin);
|
||||||
|
if (isJalaliPattern) {
|
||||||
|
const m = moment(latin, ['jYYYY/jMM/jDD', 'jYYYY-jMM-jDD', 'jYYYY/jM/jD', 'jYYYY-jM-jD']);
|
||||||
|
if (m.isValid()) return m;
|
||||||
|
}
|
||||||
|
const m = moment(latin);
|
||||||
|
if (m.isValid()) return m;
|
||||||
|
const mJalali = moment(latin, ['jYYYY/jMM/jDD', 'jYYYY-jMM-jDD']);
|
||||||
|
return mJalali.isValid() ? mJalali : null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculates the class end date in Jalali (jYYYY/jMM/jDD) based on start date, selected days, and number of sessions.
|
* Calculates the class end date in Jalali (jYYYY/jMM/jDD) based on start date, selected days, and number of sessions.
|
||||||
* @param {string|Date} startDate - Jalali string (e.g. 1403/01/01) or ISO string / Date
|
* @param {string|Date} startDate - Jalali string (e.g. 1403/01/01) or ISO string / Date
|
||||||
@@ -58,13 +82,8 @@ export function calculateClassEndDate(startDate, days = [], numberOfSessions = 0
|
|||||||
: [];
|
: [];
|
||||||
if (!validDays.length) return '';
|
if (!validDays.length) return '';
|
||||||
|
|
||||||
const latinStartDate = String(startDate)
|
let current = parseDateSmart(startDate);
|
||||||
.replace(/[۰-۹]/g, (d) => '۰۱۲۳۴۵۶۷۸۹'.indexOf(d))
|
if (!current || !current.isValid()) return '';
|
||||||
.replace(/[٠-٩]/g, (d) => '٠١٢٣٤٥٦٧٨٩'.indexOf(d))
|
|
||||||
.trim();
|
|
||||||
|
|
||||||
let current = moment(latinStartDate, ['jYYYY/jMM/jDD', 'YYYY-MM-DD', 'YYYY/MM/DD', moment.ISO_8601]);
|
|
||||||
if (!current.isValid()) return '';
|
|
||||||
|
|
||||||
let count = 0;
|
let count = 0;
|
||||||
let lastMatchingDate = null;
|
let lastMatchingDate = null;
|
||||||
@@ -85,11 +104,28 @@ export function calculateClassEndDate(startDate, days = [], numberOfSessions = 0
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Calculates the halfway due date for a class in Jalali (jYYYY/jMM/jDD).
|
* Calculates the halfway due date for a class in Jalali (jYYYY/jMM/jDD).
|
||||||
* If class has 10 sessions, due date is calculated until the 5th session.
|
* If class has 10 sessions, due date is calculated as the date of the 5th session.
|
||||||
* @param {Object} cls - Class object
|
* @param {Object} cls - Class object
|
||||||
|
* @param {Array} [sessions] - Optional array of session objects
|
||||||
* @returns {string} Jalali formatted date string, or today's date if not calculable
|
* @returns {string} Jalali formatted date string, or today's date if not calculable
|
||||||
*/
|
*/
|
||||||
export function calculateClassMidDate(cls) {
|
export function calculateClassMidDate(cls, sessions = []) {
|
||||||
|
if (Array.isArray(sessions) && sessions.length > 0) {
|
||||||
|
const validSessions = sessions
|
||||||
|
.filter((s) => s && (s.day || s.date))
|
||||||
|
.map((s) => ({
|
||||||
|
...s,
|
||||||
|
momentDate: parseDateSmart(s.day || s.date)
|
||||||
|
}))
|
||||||
|
.filter((s) => s.momentDate && s.momentDate.isValid());
|
||||||
|
|
||||||
|
if (validSessions.length > 0) {
|
||||||
|
validSessions.sort((a, b) => a.momentDate.valueOf() - b.momentDate.valueOf());
|
||||||
|
const midIndex = Math.ceil(validSessions.length / 2) - 1;
|
||||||
|
return validSessions[midIndex].momentDate.locale('fa').format('jYYYY/jMM/jDD');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!cls) return moment().locale('fa').format('jYYYY/jMM/jDD');
|
if (!cls) return moment().locale('fa').format('jYYYY/jMM/jDD');
|
||||||
|
|
||||||
const totalSessions = parseInt(cls.numberOfSessions || cls.course?.sectionCount || 0, 10);
|
const totalSessions = parseInt(cls.numberOfSessions || cls.course?.sectionCount || 0, 10);
|
||||||
@@ -101,17 +137,17 @@ export function calculateClassMidDate(cls) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (cls.startDate && cls.endDate) {
|
if (cls.startDate && cls.endDate) {
|
||||||
const startM = moment(cls.startDate);
|
const startM = parseDateSmart(cls.startDate);
|
||||||
const endM = moment(cls.endDate);
|
const endM = parseDateSmart(cls.endDate);
|
||||||
if (startM.isValid() && endM.isValid()) {
|
if (startM && startM.isValid() && endM && endM.isValid()) {
|
||||||
const midTime = startM.valueOf() + (endM.valueOf() - startM.valueOf()) / 2;
|
const midTime = startM.valueOf() + (endM.valueOf() - startM.valueOf()) / 2;
|
||||||
return moment(midTime).locale('fa').format('jYYYY/jMM/jDD');
|
return moment(midTime).locale('fa').format('jYYYY/jMM/jDD');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cls.startDate) {
|
if (cls.startDate) {
|
||||||
const startM = moment(cls.startDate);
|
const startM = parseDateSmart(cls.startDate);
|
||||||
if (startM.isValid()) return startM.locale('fa').format('jYYYY/jMM/jDD');
|
if (startM && startM.isValid()) return startM.locale('fa').format('jYYYY/jMM/jDD');
|
||||||
}
|
}
|
||||||
|
|
||||||
return moment().locale('fa').format('jYYYY/jMM/jDD');
|
return moment().locale('fa').format('jYYYY/jMM/jDD');
|
||||||
|
|||||||
@@ -44,5 +44,40 @@ describe('class schedule display', () => {
|
|||||||
// Session 5: 1403/01/18 (Sat)
|
// Session 5: 1403/01/18 (Sat)
|
||||||
assert.equal(midDate, '1403/01/18');
|
assert.equal(midDate, '1403/01/18');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('calculates class mid date from an array of existing sessions', () => {
|
||||||
|
const sessions10 = [
|
||||||
|
{ day: '2024-04-01' },
|
||||||
|
{ day: '2024-04-03' },
|
||||||
|
{ day: '2024-04-05' },
|
||||||
|
{ day: '2024-04-08' },
|
||||||
|
{ day: '2024-04-10' }, // 5th session (index 4) -> 1403/01/22
|
||||||
|
{ day: '2024-04-12' },
|
||||||
|
{ day: '2024-04-15' },
|
||||||
|
{ day: '2024-04-17' },
|
||||||
|
{ day: '2024-04-19' },
|
||||||
|
{ day: '2024-04-22' }
|
||||||
|
];
|
||||||
|
const midDate10 = calculateClassMidDate({ name: 'Class A' }, sessions10);
|
||||||
|
assert.equal(midDate10, '1403/01/22');
|
||||||
|
|
||||||
|
const sessions9 = sessions10.slice(0, 9); // 5th session is still index 4
|
||||||
|
const midDate9 = calculateClassMidDate({ name: 'Class A' }, sessions9);
|
||||||
|
assert.equal(midDate9, '1403/01/22');
|
||||||
|
|
||||||
|
const sessions4 = sessions10.slice(0, 4); // 2nd session is index 1 -> 2024-04-03 -> 1403/01/15
|
||||||
|
const midDate4 = calculateClassMidDate({ name: 'Class A' }, sessions4);
|
||||||
|
assert.equal(midDate4, '1403/01/15');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calculates class mid date with Gregorian ISO string startDate', () => {
|
||||||
|
// 2024-03-20 is 1403/01/01 (Wednesday)
|
||||||
|
const midDate = calculateClassMidDate({
|
||||||
|
startDate: '2024-03-20T00:00:00.000Z',
|
||||||
|
days: [6, 2],
|
||||||
|
numberOfSessions: 10
|
||||||
|
});
|
||||||
|
assert.equal(midDate, '1403/01/18');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -35,8 +35,10 @@ export function resolveSessionDurationHours({ hoursPerSection, startTime, endTim
|
|||||||
return calculateSessionDurationHours(startTime, endTime);
|
return calculateSessionDurationHours(startTime, endTime);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function calculatePercentageShare({ payoutPercentage = 0, revenue = 0 } = {}) {
|
export function calculatePercentageShare({ payoutPercentage = 0, revenue = 0, serviceFeePerPerson = 0, studentsCount = 0 } = {}) {
|
||||||
return (toPercentage(payoutPercentage) / 100) * toNonNegativeNumber(revenue);
|
const totalServiceFee = toNonNegativeNumber(serviceFeePerPerson) * toNonNegativeNumber(studentsCount);
|
||||||
|
const netRevenue = Math.max(0, toNonNegativeNumber(revenue) - totalServiceFee);
|
||||||
|
return (toPercentage(payoutPercentage) / 100) * netRevenue;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function calculateHourlyShare({ payoutHourlyRate = 0, sessionDurationHours = 0, sessionsCount = 0 } = {}) {
|
export function calculateHourlyShare({ payoutHourlyRate = 0, sessionDurationHours = 0, sessionsCount = 0 } = {}) {
|
||||||
|
|||||||
@@ -47,6 +47,19 @@ describe('calculateProfessorPayout', () => {
|
|||||||
assert.equal(result.totalPayout, 4_500_000);
|
assert.equal(result.totalPayout, 4_500_000);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('computes percentage-based payout deducting serviceFeePerPerson * studentsCount first', () => {
|
||||||
|
const result = calculateProfessorPayout({
|
||||||
|
payoutType: 'percentage',
|
||||||
|
payoutPercentage: 50,
|
||||||
|
revenue: 20_000_000,
|
||||||
|
serviceFeePerPerson: 400_000,
|
||||||
|
studentsCount: 2
|
||||||
|
});
|
||||||
|
// 20M - (400k * 2) = 19.2M * 50% = 9.6M
|
||||||
|
assert.equal(result.baseShare, 9_600_000);
|
||||||
|
assert.equal(result.totalPayout, 9_600_000);
|
||||||
|
});
|
||||||
|
|
||||||
it('computes hourly-based payout with extra expenses', () => {
|
it('computes hourly-based payout with extra expenses', () => {
|
||||||
const result = calculateProfessorPayout({
|
const result = calculateProfessorPayout({
|
||||||
payoutType: 'hourly',
|
payoutType: 'hourly',
|
||||||
|
|||||||
@@ -47,14 +47,27 @@
|
|||||||
@search-change="onSearch"
|
@search-change="onSearch"
|
||||||
>
|
>
|
||||||
<template #toolbar>
|
<template #toolbar>
|
||||||
<SelectButton
|
<div class="flex align-items-center justify-content-between flex-wrap gap-2 w-full">
|
||||||
v-model="attendanceFilter"
|
<SelectButton
|
||||||
:options="filterOptions"
|
v-model="attendanceFilter"
|
||||||
optionLabel="label"
|
:options="filterOptions"
|
||||||
optionValue="value"
|
optionLabel="label"
|
||||||
class="text-sm"
|
optionValue="value"
|
||||||
@change="onAttendanceFilterChange"
|
class="text-sm"
|
||||||
/>
|
@change="onAttendanceFilterChange"
|
||||||
|
/>
|
||||||
|
<Dropdown
|
||||||
|
v-model="selectedClassId"
|
||||||
|
:options="classesList"
|
||||||
|
optionLabel="name"
|
||||||
|
optionValue="_id"
|
||||||
|
placeholder="همه کلاسها"
|
||||||
|
showClear
|
||||||
|
filter
|
||||||
|
class="w-16rem text-sm"
|
||||||
|
@change="onClassFilterChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<Column field="topic" header="موضوع جلسه">
|
<Column field="topic" header="موضوع جلسه">
|
||||||
@@ -130,6 +143,7 @@ import { ref, onMounted } from 'vue';
|
|||||||
import { useDataTable } from '@/composables/useDataTable';
|
import { useDataTable } from '@/composables/useDataTable';
|
||||||
import { usePersianDate } from '@/composables/usePersianDate';
|
import { usePersianDate } from '@/composables/usePersianDate';
|
||||||
import { sessionApi } from '@/api/sessionApi';
|
import { sessionApi } from '@/api/sessionApi';
|
||||||
|
import { classApi } from '@/api/classApi';
|
||||||
import PageHeader from '@/components/common/PageHeader.vue';
|
import PageHeader from '@/components/common/PageHeader.vue';
|
||||||
import DataTableWrapper from '@/components/common/DataTableWrapper.vue';
|
import DataTableWrapper from '@/components/common/DataTableWrapper.vue';
|
||||||
import StatusTag from '@/components/common/StatusTag.vue';
|
import StatusTag from '@/components/common/StatusTag.vue';
|
||||||
@@ -137,6 +151,7 @@ import Button from 'primevue/button';
|
|||||||
import Column from 'primevue/column';
|
import Column from 'primevue/column';
|
||||||
import Tag from 'primevue/tag';
|
import Tag from 'primevue/tag';
|
||||||
import SelectButton from 'primevue/selectbutton';
|
import SelectButton from 'primevue/selectbutton';
|
||||||
|
import Dropdown from 'primevue/dropdown';
|
||||||
|
|
||||||
const { formatJalali, toPersianDigits } = usePersianDate();
|
const { formatJalali, toPersianDigits } = usePersianDate();
|
||||||
|
|
||||||
@@ -146,11 +161,14 @@ const filterOptions = [
|
|||||||
{ label: 'ثبت شده', value: 'recorded' }
|
{ label: 'ثبت شده', value: 'recorded' }
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const selectedClassId = ref(null);
|
||||||
|
const classesList = ref([]);
|
||||||
const summary = ref({ pending: 0, recorded: 0, total: 0 });
|
const summary = ref({ pending: 0, recorded: 0, total: 0 });
|
||||||
|
|
||||||
const fetchAttendanceSessions = (params) =>
|
const fetchAttendanceSessions = (params) =>
|
||||||
sessionApi.getAll({
|
sessionApi.getAll({
|
||||||
...params,
|
...params,
|
||||||
|
class: selectedClassId.value || undefined,
|
||||||
attendanceScope: params.attendanceScope || attendanceFilter.value
|
attendanceScope: params.attendanceScope || attendanceFilter.value
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -185,11 +203,29 @@ const onAttendanceFilterChange = () => {
|
|||||||
loadData();
|
loadData();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const onClassFilterChange = () => {
|
||||||
|
queryParams.class = selectedClassId.value || undefined;
|
||||||
|
queryParams.page = 1;
|
||||||
|
loadData();
|
||||||
|
loadSummary();
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadClasses = async () => {
|
||||||
|
try {
|
||||||
|
const res = await classApi.getAll({ limit: 500, sortBy: 'name', sortOrder: 'asc' });
|
||||||
|
const data = res.data || res;
|
||||||
|
classesList.value = data.items || data.classes || data.data || data || [];
|
||||||
|
} catch (err) {
|
||||||
|
classesList.value = [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const loadSummary = async () => {
|
const loadSummary = async () => {
|
||||||
try {
|
try {
|
||||||
|
const classParam = selectedClassId.value || undefined;
|
||||||
const [pendingRes, recordedRes] = await Promise.all([
|
const [pendingRes, recordedRes] = await Promise.all([
|
||||||
sessionApi.getAll({ page: 1, limit: 1, attendanceScope: 'pending', sortBy: 'day', sortOrder: 'desc' }),
|
sessionApi.getAll({ page: 1, limit: 1, attendanceScope: 'pending', class: classParam, sortBy: 'day', sortOrder: 'desc' }),
|
||||||
sessionApi.getAll({ page: 1, limit: 1, attendanceScope: 'recorded', sortBy: 'day', sortOrder: 'desc' })
|
sessionApi.getAll({ page: 1, limit: 1, attendanceScope: 'recorded', class: classParam, sortBy: 'day', sortOrder: 'desc' })
|
||||||
]);
|
]);
|
||||||
const pending = pendingRes.meta?.totalCount ?? 0;
|
const pending = pendingRes.meta?.totalCount ?? 0;
|
||||||
const recorded = recordedRes.meta?.totalCount ?? 0;
|
const recorded = recordedRes.meta?.totalCount ?? 0;
|
||||||
@@ -204,6 +240,6 @@ const loadSummary = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await Promise.all([loadData(), loadSummary()]);
|
await Promise.all([loadData(), loadSummary(), loadClasses()]);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -11,6 +11,15 @@
|
|||||||
@click="$router.push(`/financial-reports?classId=${classId}`)"
|
@click="$router.push(`/financial-reports?classId=${classId}`)"
|
||||||
/>
|
/>
|
||||||
</PermissionGate>
|
</PermissionGate>
|
||||||
|
<PermissionGate permission="payments:create">
|
||||||
|
<Button
|
||||||
|
label="صدور صورتحساب گروهی"
|
||||||
|
icon="pi pi-wallet"
|
||||||
|
severity="success"
|
||||||
|
class="ml-2"
|
||||||
|
@click="openBulkModal"
|
||||||
|
/>
|
||||||
|
</PermissionGate>
|
||||||
<Button
|
<Button
|
||||||
label="حذف کلاس"
|
label="حذف کلاس"
|
||||||
icon="pi pi-trash"
|
icon="pi pi-trash"
|
||||||
@@ -58,6 +67,10 @@
|
|||||||
<span class="text-muted text-xs block mb-1">ساعت کلاس</span>
|
<span class="text-muted text-xs block mb-1">ساعت کلاس</span>
|
||||||
<span class="font-bold text-color text-sm" dir="ltr">{{ formatClassTime(classData.startTime, classData.endTime) || '—' }}</span>
|
<span class="font-bold text-color text-sm" dir="ltr">{{ formatClassTime(classData.startTime, classData.endTime) || '—' }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="col-12 sm:col-4" v-if="classData.serviceFeePerPerson">
|
||||||
|
<span class="text-muted text-xs block mb-1">هزینه پذیرایی به ازای هر نفر</span>
|
||||||
|
<span class="font-bold text-color text-sm">{{ toPersianDigits((classData.serviceFeePerPerson || 0).toLocaleString('en-US')) }} تومان</span>
|
||||||
|
</div>
|
||||||
<div class="col-12 sm:col-4">
|
<div class="col-12 sm:col-4">
|
||||||
<span class="text-muted text-xs block mb-1">وضعیت</span>
|
<span class="text-muted text-xs block mb-1">وضعیت</span>
|
||||||
<StatusTag :status="classData.isActive !== false" />
|
<StatusTag :status="classData.isActive !== false" />
|
||||||
@@ -151,31 +164,118 @@
|
|||||||
:loading="isDeletingClass"
|
:loading="isDeletingClass"
|
||||||
@confirm="handleDeleteClass"
|
@confirm="handleDeleteClass"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- Bulk Class Payment Modal -->
|
||||||
|
<Dialog v-model:visible="bulkModalVisible" header="صدور صورتحساب گروهی برای این کلاس" modal :style="{ width: '540px' }">
|
||||||
|
<div class="flex flex-column gap-3 py-2" v-if="classData">
|
||||||
|
<div class="p-3 bg-blue-50 border-round border-1 border-blue-200 text-blue-900 text-sm flex align-items-center justify-content-between">
|
||||||
|
<span>تعداد دانشجویان ثبتنامشده:</span>
|
||||||
|
<span class="font-bold">{{ toPersianDigits((classData.students || []).length) }} نفر</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-column gap-2">
|
||||||
|
<label class="font-semibold text-sm" for="class-bulk-amount">مبلغ شهریه هر دانشجو (تومان) *</label>
|
||||||
|
<InputGroup>
|
||||||
|
<InputNumber inputId="class-bulk-amount" v-model="bulkForm.amount" class="w-full text-sm" :min="0" />
|
||||||
|
<InputGroupAddon>تومان</InputGroupAddon>
|
||||||
|
</InputGroup>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex align-items-center gap-2">
|
||||||
|
<Checkbox v-model="hasBulkDiscount" binary inputId="class-bulk-discount" />
|
||||||
|
<label for="class-bulk-discount" class="font-semibold text-sm cursor-pointer">تخفیف همگانی ؟</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="hasBulkDiscount" class="flex flex-column gap-2">
|
||||||
|
<label class="font-semibold text-sm" for="class-bulk-discount-amount">مبلغ تخفیف (تومان)</label>
|
||||||
|
<InputGroup>
|
||||||
|
<InputNumber
|
||||||
|
inputId="class-bulk-discount-amount"
|
||||||
|
v-model="bulkForm.discount"
|
||||||
|
class="w-full text-sm"
|
||||||
|
:min="0"
|
||||||
|
:max="bulkForm.amount || 0"
|
||||||
|
/>
|
||||||
|
<InputGroupAddon>تومان</InputGroupAddon>
|
||||||
|
</InputGroup>
|
||||||
|
<small class="font-semibold text-color">
|
||||||
|
مبلغ نهایی هر صورتحساب: {{ toPersianDigits(bulkPayableAmount.toLocaleString()) }} تومان
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-column gap-2">
|
||||||
|
<label class="font-semibold text-sm">تاریخ سررسید *</label>
|
||||||
|
<DatePicker v-model="bulkForm.dueDate" class="w-full text-sm" :placeholder="getTodayJalali()" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex align-items-center gap-2">
|
||||||
|
<Checkbox v-model="bulkForm.skipExisting" binary inputId="class-bulk-skip-existing" />
|
||||||
|
<label for="class-bulk-skip-existing" class="text-sm cursor-pointer font-medium">
|
||||||
|
عدم صدور مجدد برای دانشجویانی که قبلاً در این کلاس صورتحساب دارند
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-column gap-2">
|
||||||
|
<label class="font-semibold text-sm" for="class-bulk-notes">یادداشت</label>
|
||||||
|
<Textarea
|
||||||
|
id="class-bulk-notes"
|
||||||
|
v-model="bulkForm.notes"
|
||||||
|
rows="2"
|
||||||
|
class="w-full text-sm"
|
||||||
|
maxlength="5000"
|
||||||
|
placeholder="یادداشت برای تمام صورتحسابهای صادره…"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NotifyChannelsField :notify="bulkNotify" />
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<Button label="انصراف" text severity="secondary" @click="bulkModalVisible = false" />
|
||||||
|
<Button
|
||||||
|
label="صدور صورتحسابها"
|
||||||
|
icon="pi pi-check"
|
||||||
|
severity="success"
|
||||||
|
:loading="isBulkCreating"
|
||||||
|
:disabled="!(classData?.students?.length)"
|
||||||
|
@click="handleBulkCreatePayment"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted } from 'vue';
|
import { ref, reactive, computed, onMounted } from 'vue';
|
||||||
import { useRoute, useRouter } from 'vue-router';
|
import { useRoute, useRouter } from 'vue-router';
|
||||||
import { classApi } from '@/api/classApi';
|
import { classApi } from '@/api/classApi';
|
||||||
import { formatClassDays, formatClassTime } from '@/utils/classSchedule';
|
import { paymentApi } from '@/api/paymentApi';
|
||||||
|
import { formatClassDays, formatClassTime, calculateClassMidDate } from '@/utils/classSchedule';
|
||||||
import { sessionApi } from '@/api/sessionApi';
|
import { sessionApi } from '@/api/sessionApi';
|
||||||
import { usePersianDate } from '@/composables/usePersianDate';
|
import { usePersianDate } from '@/composables/usePersianDate';
|
||||||
import { useToast } from '@/composables/useToast';
|
import { useToast } from '@/composables/useToast';
|
||||||
import { usePermission } from '@/composables/usePermission';
|
import { usePermission } from '@/composables/usePermission';
|
||||||
|
import { getPayableAmount } from '@/utils/paymentAmount';
|
||||||
import PageHeader from '@/components/common/PageHeader.vue';
|
import PageHeader from '@/components/common/PageHeader.vue';
|
||||||
import StatusTag from '@/components/common/StatusTag.vue';
|
import StatusTag from '@/components/common/StatusTag.vue';
|
||||||
import TableSkeleton from '@/components/common/TableSkeleton.vue';
|
import TableSkeleton from '@/components/common/TableSkeleton.vue';
|
||||||
import ConfirmDeleteDialog from '@/components/common/ConfirmDeleteDialog.vue';
|
import ConfirmDeleteDialog from '@/components/common/ConfirmDeleteDialog.vue';
|
||||||
import PermissionGate from '@/components/common/PermissionGate.vue';
|
import PermissionGate from '@/components/common/PermissionGate.vue';
|
||||||
|
import NotifyChannelsField from '@/components/common/NotifyChannelsField.vue';
|
||||||
import Button from 'primevue/button';
|
import Button from 'primevue/button';
|
||||||
import DataTable from 'primevue/datatable';
|
import DataTable from 'primevue/datatable';
|
||||||
import Column from 'primevue/column';
|
import Column from 'primevue/column';
|
||||||
|
import Dialog from 'primevue/dialog';
|
||||||
|
import InputNumber from 'primevue/inputnumber';
|
||||||
|
import InputGroup from 'primevue/inputgroup';
|
||||||
|
import InputGroupAddon from 'primevue/inputgroupaddon';
|
||||||
|
import Checkbox from 'primevue/checkbox';
|
||||||
|
import Textarea from 'primevue/textarea';
|
||||||
|
import DatePicker from 'vue3-persian-datetime-picker';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const classId = route.params.id;
|
const classId = route.params.id;
|
||||||
const { toPersianDigits, formatJalali } = usePersianDate();
|
const { toPersianDigits, formatJalali, toGregorianIso, getTodayJalali } = usePersianDate();
|
||||||
const { showError, showSuccess } = useToast();
|
const { showError, showSuccess } = useToast();
|
||||||
const { hasPermission } = usePermission();
|
const { hasPermission } = usePermission();
|
||||||
const canManageStudents = computed(() => hasPermission('classes:register_users'));
|
const canManageStudents = computed(() => hasPermission('classes:register_users'));
|
||||||
@@ -190,6 +290,86 @@ const studentToRemove = ref(null);
|
|||||||
const deleteClassDialogVisible = ref(false);
|
const deleteClassDialogVisible = ref(false);
|
||||||
const isDeletingClass = ref(false);
|
const isDeletingClass = ref(false);
|
||||||
|
|
||||||
|
// Bulk Payment State
|
||||||
|
const bulkModalVisible = ref(false);
|
||||||
|
const isBulkCreating = ref(false);
|
||||||
|
const hasBulkDiscount = ref(false);
|
||||||
|
const bulkNotify = reactive({ sms: true, email: true, bot: true });
|
||||||
|
const bulkForm = reactive({
|
||||||
|
amount: 0,
|
||||||
|
discount: 0,
|
||||||
|
notes: '',
|
||||||
|
dueDate: getTodayJalali(),
|
||||||
|
skipExisting: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const bulkPayableAmount = computed(() => getPayableAmount({
|
||||||
|
amount: bulkForm.amount,
|
||||||
|
discount: hasBulkDiscount.value ? bulkForm.discount : 0
|
||||||
|
}));
|
||||||
|
|
||||||
|
const openBulkModal = () => {
|
||||||
|
if (!classData.value) return;
|
||||||
|
bulkForm.amount = classData.value.tuitionFee || classData.value.course?.price || 0;
|
||||||
|
if (classData.value.hasDiscount && classData.value.discount) {
|
||||||
|
hasBulkDiscount.value = true;
|
||||||
|
bulkForm.discount = classData.value.discount;
|
||||||
|
} else {
|
||||||
|
hasBulkDiscount.value = false;
|
||||||
|
bulkForm.discount = 0;
|
||||||
|
}
|
||||||
|
bulkForm.notes = '';
|
||||||
|
bulkForm.skipExisting = true;
|
||||||
|
bulkNotify.sms = true;
|
||||||
|
bulkNotify.email = true;
|
||||||
|
bulkNotify.bot = true;
|
||||||
|
bulkForm.dueDate = calculateClassMidDate(classData.value, sessions.value);
|
||||||
|
bulkModalVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBulkCreatePayment = async () => {
|
||||||
|
if (!classData.value?.students?.length) {
|
||||||
|
showError('هیچ دانشجویی در این کلاس ثبتنام نشده است');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!bulkForm.amount) { showError('لطفا مبلغ شهریه را وارد کنید'); return; }
|
||||||
|
if (!bulkForm.dueDate) { showError('لطفا تاریخ سررسید را وارد کنید'); return; }
|
||||||
|
const dueDate = toGregorianIso(bulkForm.dueDate);
|
||||||
|
if (!dueDate) { showError('تاریخ سررسید نامعتبر است'); return; }
|
||||||
|
|
||||||
|
if (hasBulkDiscount.value && (bulkForm.discount || 0) > bulkForm.amount) {
|
||||||
|
showError('مبلغ تخفیف نمیتواند بیشتر از مبلغ کل باشد');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isBulkCreating.value = true;
|
||||||
|
try {
|
||||||
|
const res = await paymentApi.createBulkClass({
|
||||||
|
classId,
|
||||||
|
amount: bulkForm.amount,
|
||||||
|
discount: hasBulkDiscount.value ? (bulkForm.discount || 0) : 0,
|
||||||
|
dueDate,
|
||||||
|
notes: bulkForm.notes,
|
||||||
|
skipExisting: bulkForm.skipExisting,
|
||||||
|
notify: { ...bulkNotify }
|
||||||
|
});
|
||||||
|
const result = res.data?.data || res.data || res;
|
||||||
|
const createdCount = result.createdCount ?? 0;
|
||||||
|
const skippedCount = result.skippedCount ?? 0;
|
||||||
|
|
||||||
|
let msg = `صورتحساب برای ${toPersianDigits(createdCount)} دانشجو با موفقیت ایجاد شد`;
|
||||||
|
if (skippedCount > 0) {
|
||||||
|
msg += ` (${toPersianDigits(skippedCount)} دانشجو به دلیل داشتن صورتحساب قبلی رد شدند)`;
|
||||||
|
}
|
||||||
|
showSuccess(msg);
|
||||||
|
bulkModalVisible.value = false;
|
||||||
|
} catch (err) {
|
||||||
|
showError(err);
|
||||||
|
} finally {
|
||||||
|
isBulkCreating.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const deleteClassDialogMessage = computed(() => {
|
const deleteClassDialogMessage = computed(() => {
|
||||||
const name = classData.value?.name;
|
const name = classData.value?.name;
|
||||||
return name
|
return name
|
||||||
|
|||||||
@@ -5,12 +5,23 @@
|
|||||||
:title="isEditMode ? 'ویرایش کلاس' : 'تعریف کلاس جدید'"
|
:title="isEditMode ? 'ویرایش کلاس' : 'تعریف کلاس جدید'"
|
||||||
:subtitle="isEditMode ? 'ویرایش کلاس، دانشجویان و جلسات' : 'تعریف کلاس جدید برای دوره'"
|
:subtitle="isEditMode ? 'ویرایش کلاس، دانشجویان و جلسات' : 'تعریف کلاس جدید برای دوره'"
|
||||||
>
|
>
|
||||||
<Button
|
<div class="flex align-items-center gap-2">
|
||||||
label="انصراف"
|
<Button
|
||||||
text
|
v-if="isEditMode && form.professor"
|
||||||
severity="secondary"
|
label="ارسال جزئیات کلاس به استاد"
|
||||||
@click="goBack"
|
icon="pi pi-send"
|
||||||
/>
|
severity="info"
|
||||||
|
outlined
|
||||||
|
class="text-sm font-semibold"
|
||||||
|
@click="openProfessorSmsDialog"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
label="انصراف"
|
||||||
|
text
|
||||||
|
severity="secondary"
|
||||||
|
@click="goBack"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
<div class="surface-card p-4 sm:p-5 border-round border-1 border-color shadow-sm mb-4">
|
<div class="surface-card p-4 sm:p-5 border-round border-1 border-color shadow-sm mb-4">
|
||||||
@@ -37,7 +48,10 @@
|
|||||||
|
|
||||||
<div class="col-12 md:col-3 flex flex-column gap-2">
|
<div class="col-12 md:col-3 flex flex-column gap-2">
|
||||||
<label class="font-semibold text-sm">شهریه کلاس</label>
|
<label class="font-semibold text-sm">شهریه کلاس</label>
|
||||||
<InputNumber v-model="form.tuitionFee" :min="0" class="w-full text-sm" suffix=" تومان" />
|
<InputGroup>
|
||||||
|
<InputNumber v-model="form.tuitionFee" :min="0" class="w-full text-sm" />
|
||||||
|
<InputGroupAddon>تومان</InputGroupAddon>
|
||||||
|
</InputGroup>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-12 md:col-3 flex flex-column gap-2">
|
<div class="col-12 md:col-3 flex flex-column gap-2">
|
||||||
@@ -59,7 +73,10 @@
|
|||||||
<div v-if="form.hasDiscount" class="grid mt-1">
|
<div v-if="form.hasDiscount" class="grid mt-1">
|
||||||
<div class="col-12 md:col-4 flex flex-column gap-2">
|
<div class="col-12 md:col-4 flex flex-column gap-2">
|
||||||
<label class="font-semibold text-sm">مبلغ تخفیف (تومان)</label>
|
<label class="font-semibold text-sm">مبلغ تخفیف (تومان)</label>
|
||||||
<InputNumber v-model="form.discount" :min="0" :max="form.tuitionFee || 0" class="w-full text-sm" suffix=" تومان" />
|
<InputGroup>
|
||||||
|
<InputNumber v-model="form.discount" :min="0" :max="form.tuitionFee || 0" class="w-full text-sm" />
|
||||||
|
<InputGroupAddon>تومان</InputGroupAddon>
|
||||||
|
</InputGroup>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12 md:col-8 flex align-items-end">
|
<div class="col-12 md:col-8 flex align-items-end">
|
||||||
<span class="font-semibold text-color text-sm">
|
<span class="font-semibold text-color text-sm">
|
||||||
@@ -92,12 +109,26 @@
|
|||||||
|
|
||||||
<div v-else class="col-12 md:col-4 flex flex-column gap-2">
|
<div v-else class="col-12 md:col-4 flex flex-column gap-2">
|
||||||
<label class="font-semibold text-sm">نرخ ساعتی استاد</label>
|
<label class="font-semibold text-sm">نرخ ساعتی استاد</label>
|
||||||
<InputNumber v-model="form.payoutHourlyRate" :min="0" class="w-full text-sm" suffix=" تومان" />
|
<InputGroup>
|
||||||
|
<InputNumber v-model="form.payoutHourlyRate" :min="0" class="w-full text-sm" />
|
||||||
|
<InputGroupAddon>تومان</InputGroupAddon>
|
||||||
|
</InputGroup>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-12 md:col-4 flex flex-column gap-2">
|
<div class="col-12 md:col-4 flex flex-column gap-2">
|
||||||
<label class="font-semibold text-sm">هزینه جانبی هر جلسه</label>
|
<label class="font-semibold text-sm">هزینه جانبی هر جلسه</label>
|
||||||
<InputNumber v-model="form.extraExpensePerSession" :min="0" class="w-full text-sm" suffix=" تومان" placeholder="مثلا: رفتوآمد، پذیرایی" />
|
<InputGroup>
|
||||||
|
<InputNumber v-model="form.extraExpensePerSession" :min="0" class="w-full text-sm" placeholder="مثلا: رفتوآمد" />
|
||||||
|
<InputGroupAddon>تومان</InputGroupAddon>
|
||||||
|
</InputGroup>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 md:col-4 flex flex-column gap-2">
|
||||||
|
<label class="font-semibold text-sm">هزینه پذیرایی به ازای هر نفر</label>
|
||||||
|
<InputGroup>
|
||||||
|
<InputNumber v-model="form.serviceFeePerPerson" :min="0" class="w-full text-sm" placeholder="کسر از شهریه قبل از درصد" />
|
||||||
|
<InputGroupAddon>تومان</InputGroupAddon>
|
||||||
|
</InputGroup>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -111,6 +142,9 @@
|
|||||||
سهم تخمینی هر جلسه: <strong class="text-color">{{ toPersianDigits(sessionShareEstimate.toLocaleString()) }} تومان</strong>
|
سهم تخمینی هر جلسه: <strong class="text-color">{{ toPersianDigits(sessionShareEstimate.toLocaleString()) }} تومان</strong>
|
||||||
(نرخ ساعتی × مدت جلسه)
|
(نرخ ساعتی × مدت جلسه)
|
||||||
</span>
|
</span>
|
||||||
|
<span class="text-xs text-muted" v-if="form.payoutType === 'percentage' && form.serviceFeePerPerson">
|
||||||
|
هزینه پذیرایی ({{ toPersianDigits((form.serviceFeePerPerson || 0).toLocaleString()) }} تومان به ازای هر نفر) پیش از محاسبه درصد سهم استاد، از شهریه کسر خواهد شد.
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -273,6 +307,7 @@
|
|||||||
:course-id="form.course ? String(form.course) : ''"
|
:course-id="form.course ? String(form.course) : ''"
|
||||||
:professor-id="form.professor ? String(form.professor) : null"
|
:professor-id="form.professor ? String(form.professor) : null"
|
||||||
:default-session-count="defaultSessionCount"
|
:default-session-count="defaultSessionCount"
|
||||||
|
:start-date="form.startDate"
|
||||||
:days="form.days"
|
:days="form.days"
|
||||||
:start-time="form.startTime"
|
:start-time="form.startTime"
|
||||||
:end-time="form.endTime"
|
:end-time="form.endTime"
|
||||||
@@ -280,12 +315,23 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<ConfirmDeleteDialog
|
<ConfirmDeleteDialog
|
||||||
v-model="removeDialogVisible"
|
v-model:visible="removeDialogVisible"
|
||||||
title="حذف از کلاس"
|
title="حذف دانشجو از کلاس"
|
||||||
:message="removeDialogMessage"
|
:message="removeDialogMessage"
|
||||||
:loading="!!removingUserId"
|
:loading="!!removingUserId"
|
||||||
@confirm="handleRemoveStudent"
|
@confirm="handleRemoveStudent"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<SmsPreviewConfirmDialog
|
||||||
|
v-model:visible="isProfessorSmsDialogVisible"
|
||||||
|
title="ارسال جزئیات برنامه کلاس به استاد"
|
||||||
|
:recipientName="selectedProfessor?.name || 'استاد'"
|
||||||
|
:recipientPhone="selectedProfessor?.phoneNumber || ''"
|
||||||
|
:messageText="professorSmsText"
|
||||||
|
:loading="isSendingProfessorSms"
|
||||||
|
confirmLabel="ارسال پیامک به استاد"
|
||||||
|
@confirm="sendProfessorClassPlanSms"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -305,9 +351,12 @@ import AdminNotesField from '@/components/common/AdminNotesField.vue';
|
|||||||
import ClassSessionsSection from '@/components/classes/ClassSessionsSection.vue';
|
import ClassSessionsSection from '@/components/classes/ClassSessionsSection.vue';
|
||||||
import NotifyChannelsField from '@/components/common/NotifyChannelsField.vue';
|
import NotifyChannelsField from '@/components/common/NotifyChannelsField.vue';
|
||||||
import ConfirmDeleteDialog from '@/components/common/ConfirmDeleteDialog.vue';
|
import ConfirmDeleteDialog from '@/components/common/ConfirmDeleteDialog.vue';
|
||||||
|
import SmsPreviewConfirmDialog from '@/components/common/SmsPreviewConfirmDialog.vue';
|
||||||
import PermissionGate from '@/components/common/PermissionGate.vue';
|
import PermissionGate from '@/components/common/PermissionGate.vue';
|
||||||
import InputText from 'primevue/inputtext';
|
import InputText from 'primevue/inputtext';
|
||||||
import InputNumber from 'primevue/inputnumber';
|
import InputNumber from 'primevue/inputnumber';
|
||||||
|
import InputGroup from 'primevue/inputgroup';
|
||||||
|
import InputGroupAddon from 'primevue/inputgroupaddon';
|
||||||
import Dropdown from 'primevue/select';
|
import Dropdown from 'primevue/select';
|
||||||
import MultiSelect from 'primevue/multiselect';
|
import MultiSelect from 'primevue/multiselect';
|
||||||
import InputSwitch from 'primevue/toggleswitch';
|
import InputSwitch from 'primevue/toggleswitch';
|
||||||
@@ -365,6 +414,7 @@ const form = reactive({
|
|||||||
payoutPercentage: 0,
|
payoutPercentage: 0,
|
||||||
payoutHourlyRate: 0,
|
payoutHourlyRate: 0,
|
||||||
extraExpensePerSession: 0,
|
extraExpensePerSession: 0,
|
||||||
|
serviceFeePerPerson: 0,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
adminNotes: []
|
adminNotes: []
|
||||||
});
|
});
|
||||||
@@ -490,6 +540,7 @@ const fetchData = async () => {
|
|||||||
payoutPercentage: data.payoutPercentage || 0,
|
payoutPercentage: data.payoutPercentage || 0,
|
||||||
payoutHourlyRate: data.payoutHourlyRate || 0,
|
payoutHourlyRate: data.payoutHourlyRate || 0,
|
||||||
extraExpensePerSession: data.extraExpensePerSession || 0,
|
extraExpensePerSession: data.extraExpensePerSession || 0,
|
||||||
|
serviceFeePerPerson: data.serviceFeePerPerson || 0,
|
||||||
isActive: data.isActive !== false,
|
isActive: data.isActive !== false,
|
||||||
adminNotes: Array.isArray(data.adminNotes) ? [...data.adminNotes] : []
|
adminNotes: Array.isArray(data.adminNotes) ? [...data.adminNotes] : []
|
||||||
});
|
});
|
||||||
@@ -545,6 +596,7 @@ const handleSubmit = async () => {
|
|||||||
payoutPercentage: form.payoutType === 'percentage' ? form.payoutPercentage : 0,
|
payoutPercentage: form.payoutType === 'percentage' ? form.payoutPercentage : 0,
|
||||||
payoutHourlyRate: form.payoutType === 'hourly' ? form.payoutHourlyRate : 0,
|
payoutHourlyRate: form.payoutType === 'hourly' ? form.payoutHourlyRate : 0,
|
||||||
extraExpensePerSession: form.extraExpensePerSession,
|
extraExpensePerSession: form.extraExpensePerSession,
|
||||||
|
serviceFeePerPerson: form.serviceFeePerPerson,
|
||||||
isActive: form.isActive,
|
isActive: form.isActive,
|
||||||
adminNotes: (form.adminNotes || []).map((n) => String(n).trim()).filter(Boolean)
|
adminNotes: (form.adminNotes || []).map((n) => String(n).trim()).filter(Boolean)
|
||||||
};
|
};
|
||||||
@@ -614,5 +666,54 @@ watch(() => form.course, (courseId) => {
|
|||||||
selectedCourseHoursPerSection.value = course?.hoursPerSection ?? null;
|
selectedCourseHoursPerSection.value = course?.hoursPerSection ?? null;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const isProfessorSmsDialogVisible = ref(false);
|
||||||
|
const isSendingProfessorSms = ref(false);
|
||||||
|
const professorSmsText = ref('');
|
||||||
|
|
||||||
|
const selectedProfessor = computed(() => {
|
||||||
|
if (!form.professor) return null;
|
||||||
|
return professors.value.find((p) => String(p._id) === String(form.professor)) || null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const openProfessorSmsDialog = () => {
|
||||||
|
const prof = selectedProfessor.value;
|
||||||
|
if (!prof) {
|
||||||
|
showError('استاد این کلاس مشخص نشده است.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const profName = `${prof.name || ''} ${prof.surname || ''}`.trim() || 'استاد';
|
||||||
|
const className = form.name || 'کلاس';
|
||||||
|
const selectedDayLabels = (form.days || [])
|
||||||
|
.map((d) => weekdays.find((w) => w.value === d)?.label)
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('، ') || 'طبق هماهنگی';
|
||||||
|
const classTimes = (form.startTime && form.endTime)
|
||||||
|
? `${form.startTime} الی ${form.endTime}`
|
||||||
|
: (form.startTime || form.endTime || 'طبق هماهنگی');
|
||||||
|
const startDateStr = form.startDate ? toPersianDigits(form.startDate) : '—';
|
||||||
|
const endDateStr = calculatedEndDateDisplay.value !== '—' ? calculatedEndDateDisplay.value : (form.endDate ? toPersianDigits(form.endDate) : '—');
|
||||||
|
|
||||||
|
professorSmsText.value = `با سلام و وقت بخیر، استاد ${profName}،
|
||||||
|
برنامه کلاس ${className} شما به شرح زیر می باشد:
|
||||||
|
${selectedDayLabels}، ${classTimes}
|
||||||
|
از ${startDateStr} الی ${endDateStr}`;
|
||||||
|
|
||||||
|
isProfessorSmsDialogVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendProfessorClassPlanSms = async () => {
|
||||||
|
if (!classId) return;
|
||||||
|
isSendingProfessorSms.value = true;
|
||||||
|
try {
|
||||||
|
await classApi.sendPlanToProfessor(classId);
|
||||||
|
showSuccess('برنامه کلاس با موفقیت برای استاد پیامک شد.');
|
||||||
|
isProfessorSmsDialogVisible.value = false;
|
||||||
|
} catch (err) {
|
||||||
|
showError(err);
|
||||||
|
} finally {
|
||||||
|
isSendingProfessorSms.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
onMounted(fetchData);
|
onMounted(fetchData);
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -25,7 +25,10 @@
|
|||||||
|
|
||||||
<div class="col-12 md:col-3 flex flex-column gap-2">
|
<div class="col-12 md:col-3 flex flex-column gap-2">
|
||||||
<label class="font-semibold text-sm">{{ $t('courses.price') }} *</label>
|
<label class="font-semibold text-sm">{{ $t('courses.price') }} *</label>
|
||||||
<InputNumber v-model="form.price" class="w-full text-sm" :class="{ 'p-invalid': errors.price }" suffix=" تومان" />
|
<InputGroup>
|
||||||
|
<InputNumber v-model="form.price" class="w-full text-sm" :class="{ 'p-invalid': errors.price }" />
|
||||||
|
<InputGroupAddon>تومان</InputGroupAddon>
|
||||||
|
</InputGroup>
|
||||||
<small v-if="errors.price" class="text-red-500 text-xs">{{ errors.price }}</small>
|
<small v-if="errors.price" class="text-red-500 text-xs">{{ errors.price }}</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -158,6 +161,8 @@ import StatusTag from '@/components/common/StatusTag.vue';
|
|||||||
import TableSkeleton from '@/components/common/TableSkeleton.vue';
|
import TableSkeleton from '@/components/common/TableSkeleton.vue';
|
||||||
import InputText from 'primevue/inputtext';
|
import InputText from 'primevue/inputtext';
|
||||||
import InputNumber from 'primevue/inputnumber';
|
import InputNumber from 'primevue/inputnumber';
|
||||||
|
import InputGroup from 'primevue/inputgroup';
|
||||||
|
import InputGroupAddon from 'primevue/inputgroupaddon';
|
||||||
import Dropdown from 'primevue/select';
|
import Dropdown from 'primevue/select';
|
||||||
import Textarea from 'primevue/textarea';
|
import Textarea from 'primevue/textarea';
|
||||||
import InputSwitch from 'primevue/toggleswitch';
|
import InputSwitch from 'primevue/toggleswitch';
|
||||||
|
|||||||
@@ -0,0 +1,664 @@
|
|||||||
|
<!-- /src/views/employeeTimings/EmployeeTimingListView.vue -->
|
||||||
|
<template>
|
||||||
|
<div class="employee-timing-list-view">
|
||||||
|
<PageHeader
|
||||||
|
title="ورود و خروج کارمندان"
|
||||||
|
subtitle="ثبت، ویرایش و مدیریت زمان تردد، ساعت کاری و یادداشتهای کارمندان"
|
||||||
|
>
|
||||||
|
<PermissionGate permission="employee_timings:create">
|
||||||
|
<Button
|
||||||
|
label="ثبت تردد جدید"
|
||||||
|
icon="pi pi-plus"
|
||||||
|
@click="openCreateDialog"
|
||||||
|
/>
|
||||||
|
</PermissionGate>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<!-- Top Summary KPI Cards -->
|
||||||
|
<div class="grid mb-4">
|
||||||
|
<div class="col-12 sm:col-6 lg:col-3">
|
||||||
|
<div class="surface-card p-4 border-round-xl border-1 border-color shadow-sm h-full flex align-items-center justify-content-between">
|
||||||
|
<div>
|
||||||
|
<span class="text-muted text-xs font-semibold block mb-1">کل رکوردهای تردد</span>
|
||||||
|
<span class="text-3xl font-bold text-color">{{ toPersianDigits(summary.total || 0) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-3rem h-3rem border-round-xl flex align-items-center justify-content-center bg-primary-light text-primary">
|
||||||
|
<i class="pi pi-clock text-xl"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 sm:col-6 lg:col-3">
|
||||||
|
<div class="surface-card p-4 border-round-xl border-1 border-color shadow-sm h-full flex align-items-center justify-content-between">
|
||||||
|
<div>
|
||||||
|
<span class="text-muted text-xs font-semibold block mb-1">ترددهای کامل</span>
|
||||||
|
<span class="text-3xl font-bold text-green-500">{{ toPersianDigits(summary.complete || 0) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-3rem h-3rem border-round-xl flex align-items-center justify-content-center bg-green-50 text-green-600">
|
||||||
|
<i class="pi pi-check-circle text-xl"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 sm:col-6 lg:col-3">
|
||||||
|
<div
|
||||||
|
class="surface-card p-4 border-round-xl border-1 shadow-sm h-full flex align-items-center justify-content-between cursor-pointer transition-colors"
|
||||||
|
:class="statusFilter === 'missing_exit' ? 'border-orange-500 bg-orange-50' : 'border-color'"
|
||||||
|
@click="setStatusQuickFilter('missing_exit')"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<span class="text-muted text-xs font-semibold block mb-1">فاقد زمان خروج (هشدار)</span>
|
||||||
|
<span class="text-3xl font-bold text-orange-500">{{ toPersianDigits(summary.missingExit || 0) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-3rem h-3rem border-round-xl flex align-items-center justify-content-center bg-orange-100 text-orange-600">
|
||||||
|
<i class="pi pi-exclamation-triangle text-xl"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 sm:col-6 lg:col-3">
|
||||||
|
<div
|
||||||
|
class="surface-card p-4 border-round-xl border-1 shadow-sm h-full flex align-items-center justify-content-between cursor-pointer transition-colors"
|
||||||
|
:class="statusFilter === 'missing_entry' ? 'border-red-500 bg-red-50' : 'border-color'"
|
||||||
|
@click="setStatusQuickFilter('missing_entry')"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<span class="text-muted text-xs font-semibold block mb-1">فاقد زمان ورود (هشدار)</span>
|
||||||
|
<span class="text-3xl font-bold text-red-500">{{ toPersianDigits(summary.missingEntry || 0) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-3rem h-3rem border-round-xl flex align-items-center justify-content-center bg-red-100 text-red-600">
|
||||||
|
<i class="pi pi-exclamation-circle text-xl"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Data Table & Filter Toolbar -->
|
||||||
|
<DataTableWrapper
|
||||||
|
:items="items"
|
||||||
|
:totalCount="totalCount"
|
||||||
|
:page="queryParams.page"
|
||||||
|
:limit="queryParams.limit"
|
||||||
|
:sortBy="queryParams.sortBy"
|
||||||
|
:sortOrder="queryParams.sortOrder"
|
||||||
|
:loading="isLoading"
|
||||||
|
@page-change="onPageChange"
|
||||||
|
@sort-change="onSort"
|
||||||
|
@search-change="onSearch"
|
||||||
|
>
|
||||||
|
<template #toolbar>
|
||||||
|
<div class="flex align-items-center justify-content-between flex-wrap gap-3 w-full">
|
||||||
|
<!-- Status filter -->
|
||||||
|
<div class="flex align-items-center gap-2 flex-wrap">
|
||||||
|
<SelectButton
|
||||||
|
v-model="statusFilter"
|
||||||
|
:options="statusOptions"
|
||||||
|
optionLabel="label"
|
||||||
|
optionValue="value"
|
||||||
|
class="text-xs"
|
||||||
|
@change="onStatusFilterChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Employee Dropdown filter & Date pickers -->
|
||||||
|
<div class="flex align-items-center gap-2 flex-wrap">
|
||||||
|
<Dropdown
|
||||||
|
v-model="selectedUserId"
|
||||||
|
:options="usersList"
|
||||||
|
optionLabel="name"
|
||||||
|
optionValue="_id"
|
||||||
|
placeholder="همه کارمندان / کاربران"
|
||||||
|
showClear
|
||||||
|
filter
|
||||||
|
class="w-16rem text-sm"
|
||||||
|
@change="onUserFilterChange"
|
||||||
|
>
|
||||||
|
<template #option="{ option }">
|
||||||
|
<div class="flex flex-column">
|
||||||
|
<span class="font-bold text-xs">{{ option.name }}</span>
|
||||||
|
<span class="text-muted text-xs" dir="ltr">{{ option.phoneNumber || option.nationalIdCode || '' }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dropdown>
|
||||||
|
|
||||||
|
<DatePicker
|
||||||
|
v-model="startDateFilter"
|
||||||
|
placeholder="از تاریخ"
|
||||||
|
class="w-10rem text-sm"
|
||||||
|
@update:modelValue="onDateFilterChange"
|
||||||
|
/>
|
||||||
|
<DatePicker
|
||||||
|
v-model="endDateFilter"
|
||||||
|
placeholder="تا تاریخ"
|
||||||
|
class="w-10rem text-sm"
|
||||||
|
@update:modelValue="onDateFilterChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Employee info -->
|
||||||
|
<Column field="user" header="کارمند / کاربر">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<div class="flex align-items-center gap-2">
|
||||||
|
<div class="w-2rem h-2rem border-round-circle flex align-items-center justify-content-center bg-primary-light text-primary font-bold text-xs">
|
||||||
|
{{ (data.user?.name || 'ک').charAt(0) }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="font-bold text-color block text-sm">{{ data.user?.name || '—' }}</span>
|
||||||
|
<div class="flex align-items-center gap-2 text-xs text-muted">
|
||||||
|
<span v-if="data.user?.nationalIdCode" dir="ltr">کد ملی: {{ data.user.nationalIdCode }}</span>
|
||||||
|
<span v-if="data.user?.phoneNumber" dir="ltr">{{ data.user.phoneNumber }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<!-- Date -->
|
||||||
|
<Column field="date" header="تاریخ" sortable sortField="date">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<div class="flex flex-column">
|
||||||
|
<span class="font-semibold text-color">{{ formatJalali(data.date) }}</span>
|
||||||
|
<span class="text-xs text-muted">{{ formatJalaliDayName(data.date) }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<!-- Entry time -->
|
||||||
|
<Column field="entryTime" header="زمان ورود">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<span v-if="data.entryTime" class="font-mono text-sm font-bold text-green-600 bg-green-50 px-2 py-1 border-round" dir="ltr">
|
||||||
|
{{ toPersianDigits(data.entryTime) }}
|
||||||
|
</span>
|
||||||
|
<span v-else class="text-xs text-orange-500 font-semibold bg-orange-50 px-2 py-1 border-round">
|
||||||
|
ثبت نشده
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<!-- Exit time -->
|
||||||
|
<Column field="exitTime" header="زمان خروج">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<span v-if="data.exitTime" class="font-mono text-sm font-bold text-blue-600 bg-blue-50 px-2 py-1 border-round" dir="ltr">
|
||||||
|
{{ toPersianDigits(data.exitTime) }}
|
||||||
|
</span>
|
||||||
|
<span v-else class="text-xs text-orange-500 font-semibold bg-orange-50 px-2 py-1 border-round">
|
||||||
|
ثبت نشده
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<!-- Duration -->
|
||||||
|
<Column field="durationFormatted" header="مدت کارکرد">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<span class="text-xs font-semibold text-color">
|
||||||
|
{{ data.durationFormatted ? toPersianDigits(data.durationFormatted) : '—' }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<!-- Status & Warnings -->
|
||||||
|
<Column field="status" header="وضعیت / هشدار">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<Tag
|
||||||
|
v-if="data.status === 'complete'"
|
||||||
|
value="کامل"
|
||||||
|
severity="success"
|
||||||
|
icon="pi pi-check-circle"
|
||||||
|
class="text-xs"
|
||||||
|
/>
|
||||||
|
<Tag
|
||||||
|
v-else-if="data.status === 'missing_exit'"
|
||||||
|
value="فاقد زمان خروج"
|
||||||
|
severity="warn"
|
||||||
|
icon="pi pi-exclamation-triangle"
|
||||||
|
class="text-xs"
|
||||||
|
v-tooltip.top="'زمان ورود ثبت شده ولی خروج ثبت نشده است'"
|
||||||
|
/>
|
||||||
|
<Tag
|
||||||
|
v-else-if="data.status === 'missing_entry'"
|
||||||
|
value="فاقد زمان ورود"
|
||||||
|
severity="danger"
|
||||||
|
icon="pi pi-exclamation-triangle"
|
||||||
|
class="text-xs"
|
||||||
|
v-tooltip.top="'زمان خروج ثبت شده ولی ورود ثبت نشده است'"
|
||||||
|
/>
|
||||||
|
<Tag
|
||||||
|
v-else
|
||||||
|
value="ناقص (بدون ورود و خروج)"
|
||||||
|
severity="danger"
|
||||||
|
icon="pi pi-exclamation-triangle"
|
||||||
|
class="text-xs"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<!-- Note -->
|
||||||
|
<Column field="note" header="یادداشت">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<span
|
||||||
|
v-if="data.note"
|
||||||
|
class="text-xs text-muted line-height-2 block max-w-16rem overflow-hidden text-overflow-ellipsis white-space-nowrap"
|
||||||
|
:title="data.note"
|
||||||
|
>
|
||||||
|
{{ data.note }}
|
||||||
|
</span>
|
||||||
|
<span v-else class="text-muted text-xs">—</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<Column header="عملیات" style="width: 100px">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<div class="flex align-items-center gap-1">
|
||||||
|
<PermissionGate permission="employee_timings:update">
|
||||||
|
<Button
|
||||||
|
icon="pi pi-pencil"
|
||||||
|
text
|
||||||
|
rounded
|
||||||
|
size="small"
|
||||||
|
severity="secondary"
|
||||||
|
title="ویرایش"
|
||||||
|
@click="openEditDialog(data)"
|
||||||
|
/>
|
||||||
|
</PermissionGate>
|
||||||
|
<PermissionGate permission="employee_timings:delete">
|
||||||
|
<Button
|
||||||
|
icon="pi pi-trash"
|
||||||
|
text
|
||||||
|
rounded
|
||||||
|
size="small"
|
||||||
|
severity="danger"
|
||||||
|
title="حذف"
|
||||||
|
@click="confirmDelete(data)"
|
||||||
|
/>
|
||||||
|
</PermissionGate>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
</DataTableWrapper>
|
||||||
|
|
||||||
|
<!-- Create / Edit Dialog -->
|
||||||
|
<Dialog
|
||||||
|
v-model:visible="isFormDialogVisible"
|
||||||
|
modal
|
||||||
|
:header="editingId ? 'ویرایش رکورد تردد کارمند' : 'ثبت رکورد جدید تردد کارمند'"
|
||||||
|
:style="{ width: '520px', maxWidth: '95vw' }"
|
||||||
|
>
|
||||||
|
<form @submit.prevent="saveForm" class="flex flex-column gap-3 py-1">
|
||||||
|
<!-- User selector -->
|
||||||
|
<div class="flex flex-column gap-1">
|
||||||
|
<label class="font-semibold text-sm">کارمند / کاربر *</label>
|
||||||
|
<Dropdown
|
||||||
|
v-model="form.user"
|
||||||
|
:options="usersList"
|
||||||
|
optionLabel="name"
|
||||||
|
optionValue="_id"
|
||||||
|
placeholder="انتخاب کارمند"
|
||||||
|
filter
|
||||||
|
class="w-full text-sm"
|
||||||
|
>
|
||||||
|
<template #option="{ option }">
|
||||||
|
<div class="flex flex-column">
|
||||||
|
<span class="font-bold text-xs">{{ option.name }}</span>
|
||||||
|
<span class="text-muted text-xs" dir="ltr">{{ option.phoneNumber || option.nationalIdCode || '' }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Date picker -->
|
||||||
|
<div class="flex flex-column gap-1">
|
||||||
|
<label class="font-semibold text-sm">تاریخ تردد *</label>
|
||||||
|
<DatePicker
|
||||||
|
v-model="form.date"
|
||||||
|
class="w-full text-sm"
|
||||||
|
:placeholder="getTodayJalali()"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Entry and Exit times with quick set now buttons -->
|
||||||
|
<div class="grid">
|
||||||
|
<div class="col-12 sm:col-6 flex flex-column gap-1">
|
||||||
|
<div class="flex align-items-center justify-content-between">
|
||||||
|
<label class="font-semibold text-sm">زمان ورود</label>
|
||||||
|
<Button
|
||||||
|
label="اکنون"
|
||||||
|
icon="pi pi-clock"
|
||||||
|
text
|
||||||
|
size="small"
|
||||||
|
class="text-xs p-1"
|
||||||
|
@click="setNow('entryTime')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<InputText
|
||||||
|
v-model.trim="form.entryTime"
|
||||||
|
placeholder="08:30"
|
||||||
|
class="w-full text-sm font-mono text-center"
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 sm:col-6 flex flex-column gap-1">
|
||||||
|
<div class="flex align-items-center justify-content-between">
|
||||||
|
<label class="font-semibold text-sm">زمان خروج</label>
|
||||||
|
<Button
|
||||||
|
label="اکنون"
|
||||||
|
icon="pi pi-clock"
|
||||||
|
text
|
||||||
|
size="small"
|
||||||
|
class="text-xs p-1"
|
||||||
|
@click="setNow('exitTime')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<InputText
|
||||||
|
v-model.trim="form.exitTime"
|
||||||
|
placeholder="17:00"
|
||||||
|
class="w-full text-sm font-mono text-center"
|
||||||
|
dir="ltr"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Incomplete Warning Callout in Modal -->
|
||||||
|
<div
|
||||||
|
v-if="formWarningMessage"
|
||||||
|
class="p-3 border-round-lg bg-orange-50 border-1 border-orange-200 text-orange-700 text-xs flex align-items-start gap-2"
|
||||||
|
>
|
||||||
|
<i class="pi pi-exclamation-triangle text-base mt-1 flex-shrink-0"></i>
|
||||||
|
<div class="flex flex-column gap-1">
|
||||||
|
<span class="font-bold">هشدار عدم تکمیل رکورد:</span>
|
||||||
|
<span>{{ formWarningMessage }}. این رکورد با وضعیت هشدار ذخیره میشود.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Note Textarea -->
|
||||||
|
<div class="flex flex-column gap-1">
|
||||||
|
<label class="font-semibold text-sm">یادداشت و توضیحات</label>
|
||||||
|
<Textarea
|
||||||
|
v-model="form.note"
|
||||||
|
rows="3"
|
||||||
|
class="w-full text-sm line-height-3 surface-card"
|
||||||
|
autoResize
|
||||||
|
placeholder="یادداشت، مرخصی ساعتی، مأموریت، تأخیر یا توضیحات تردد..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex justify-content-end gap-2 pt-2">
|
||||||
|
<Button
|
||||||
|
label="انصراف"
|
||||||
|
text
|
||||||
|
severity="secondary"
|
||||||
|
:disabled="isSubmitting"
|
||||||
|
@click="isFormDialogVisible = false"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
:label="editingId ? 'بهروزرسانی تردد' : 'ثبت تردد'"
|
||||||
|
icon="pi pi-check"
|
||||||
|
:loading="isSubmitting"
|
||||||
|
@click="saveForm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, onMounted, reactive, ref } from 'vue';
|
||||||
|
import { useConfirm } from 'primevue/useconfirm';
|
||||||
|
import Button from 'primevue/button';
|
||||||
|
import Column from 'primevue/column';
|
||||||
|
import Dialog from 'primevue/dialog';
|
||||||
|
import Dropdown from 'primevue/dropdown';
|
||||||
|
import InputText from 'primevue/inputtext';
|
||||||
|
import Textarea from 'primevue/textarea';
|
||||||
|
import SelectButton from 'primevue/selectbutton';
|
||||||
|
import Tag from 'primevue/tag';
|
||||||
|
import PageHeader from '@/components/common/PageHeader.vue';
|
||||||
|
import DataTableWrapper from '@/components/common/DataTableWrapper.vue';
|
||||||
|
import PermissionGate from '@/components/common/PermissionGate.vue';
|
||||||
|
import DatePicker from 'vue3-persian-datetime-picker';
|
||||||
|
import { employeeTimingApi } from '@/api/employeeTimingApi';
|
||||||
|
import { userApi } from '@/api/userApi';
|
||||||
|
import { useDataTable } from '@/composables/useDataTable';
|
||||||
|
import { useToast } from '@/composables/useToast';
|
||||||
|
import { usePersianDate } from '@/composables/usePersianDate';
|
||||||
|
|
||||||
|
const confirm = useConfirm();
|
||||||
|
const { showSuccess, showError } = useToast();
|
||||||
|
const { toPersianDigits, formatJalali, toJalaliPickerValue, toGregorianIso, getTodayJalali } = usePersianDate();
|
||||||
|
|
||||||
|
const statusFilter = ref('all');
|
||||||
|
const selectedUserId = ref(null);
|
||||||
|
const startDateFilter = ref(null);
|
||||||
|
const endDateFilter = ref(null);
|
||||||
|
|
||||||
|
const summary = ref({
|
||||||
|
total: 0,
|
||||||
|
complete: 0,
|
||||||
|
incomplete: 0,
|
||||||
|
missingExit: 0,
|
||||||
|
missingEntry: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
const usersList = ref([]);
|
||||||
|
const isFormDialogVisible = ref(false);
|
||||||
|
const editingId = ref(null);
|
||||||
|
const isSubmitting = ref(false);
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
user: null,
|
||||||
|
date: null,
|
||||||
|
entryTime: '',
|
||||||
|
exitTime: '',
|
||||||
|
note: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
const statusOptions = [
|
||||||
|
{ label: 'همه', value: 'all' },
|
||||||
|
{ label: 'دارای هشدار (ناقص)', value: 'incomplete' },
|
||||||
|
{ label: 'فاقد زمان خروج', value: 'missing_exit' },
|
||||||
|
{ label: 'فاقد زمان ورود', value: 'missing_entry' },
|
||||||
|
{ label: 'کامل', value: 'complete' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const fetchTimings = (params) => {
|
||||||
|
const query = { ...params };
|
||||||
|
if (statusFilter.value && statusFilter.value !== 'all') {
|
||||||
|
query.status = statusFilter.value;
|
||||||
|
}
|
||||||
|
if (selectedUserId.value) {
|
||||||
|
query.user = selectedUserId.value;
|
||||||
|
}
|
||||||
|
if (startDateFilter.value) {
|
||||||
|
query.startDate = toGregorianIso(startDateFilter.value) || startDateFilter.value;
|
||||||
|
}
|
||||||
|
if (endDateFilter.value) {
|
||||||
|
query.endDate = toGregorianIso(endDateFilter.value) || endDateFilter.value;
|
||||||
|
}
|
||||||
|
return employeeTimingApi.getAll(query);
|
||||||
|
};
|
||||||
|
|
||||||
|
const {
|
||||||
|
items,
|
||||||
|
totalCount,
|
||||||
|
isLoading,
|
||||||
|
queryParams,
|
||||||
|
onPageChange,
|
||||||
|
onSort,
|
||||||
|
onSearch,
|
||||||
|
refresh
|
||||||
|
} = useDataTable(fetchTimings, {
|
||||||
|
sortBy: 'date',
|
||||||
|
sortOrder: 'desc'
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadSummary = async () => {
|
||||||
|
try {
|
||||||
|
const params = {};
|
||||||
|
if (selectedUserId.value) params.user = selectedUserId.value;
|
||||||
|
if (startDateFilter.value) params.startDate = toGregorianIso(startDateFilter.value) || startDateFilter.value;
|
||||||
|
if (endDateFilter.value) params.endDate = toGregorianIso(endDateFilter.value) || endDateFilter.value;
|
||||||
|
const response = await employeeTimingApi.getSummary(params);
|
||||||
|
const data = response?.data?.data || response?.data || {};
|
||||||
|
summary.value = {
|
||||||
|
total: data.total || 0,
|
||||||
|
complete: data.complete || 0,
|
||||||
|
incomplete: data.incomplete || 0,
|
||||||
|
missingExit: data.missingExit || 0,
|
||||||
|
missingEntry: data.missingEntry || 0
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
// silent catch
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadUsers = async () => {
|
||||||
|
try {
|
||||||
|
const response = await userApi.getAll({ limit: 200 });
|
||||||
|
const data = response?.data?.data || response?.data || [];
|
||||||
|
usersList.value = Array.isArray(data) ? data : [];
|
||||||
|
} catch (err) {
|
||||||
|
// silent catch
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onStatusFilterChange = () => {
|
||||||
|
queryParams.page = 1;
|
||||||
|
refresh();
|
||||||
|
};
|
||||||
|
|
||||||
|
const setStatusQuickFilter = (status) => {
|
||||||
|
if (statusFilter.value === status) {
|
||||||
|
statusFilter.value = 'all';
|
||||||
|
} else {
|
||||||
|
statusFilter.value = status;
|
||||||
|
}
|
||||||
|
onStatusFilterChange();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onUserFilterChange = () => {
|
||||||
|
queryParams.page = 1;
|
||||||
|
refresh();
|
||||||
|
loadSummary();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onDateFilterChange = () => {
|
||||||
|
queryParams.page = 1;
|
||||||
|
refresh();
|
||||||
|
loadSummary();
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatJalaliDayName = (dateStr) => {
|
||||||
|
if (!dateStr) return '';
|
||||||
|
try {
|
||||||
|
const d = new Date(dateStr);
|
||||||
|
return d.toLocaleDateString('fa-IR', { weekday: 'long' });
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const formWarningMessage = computed(() => {
|
||||||
|
const hasEntry = Boolean(form.entryTime && form.entryTime.trim());
|
||||||
|
const hasExit = Boolean(form.exitTime && form.exitTime.trim());
|
||||||
|
if (hasEntry && !hasExit) return 'زمان خروج ثبت نشده است';
|
||||||
|
if (!hasEntry && hasExit) return 'زمان ورود ثبت نشده است';
|
||||||
|
if (!hasEntry && !hasExit) return 'زمان ورود و خروج هیچکدام ثبت نشده است';
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const setNow = (field) => {
|
||||||
|
const now = new Date();
|
||||||
|
const hh = String(now.getHours()).padStart(2, '0');
|
||||||
|
const mm = String(now.getMinutes()).padStart(2, '0');
|
||||||
|
form[field] = `${hh}:${mm}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const openCreateDialog = () => {
|
||||||
|
editingId.value = null;
|
||||||
|
form.user = selectedUserId.value || (usersList.value[0]?._id || null);
|
||||||
|
form.date = getTodayJalali();
|
||||||
|
form.entryTime = '';
|
||||||
|
form.exitTime = '';
|
||||||
|
form.note = '';
|
||||||
|
isFormDialogVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEditDialog = (record) => {
|
||||||
|
editingId.value = record._id || record.id;
|
||||||
|
form.user = record.user?._id || record.user;
|
||||||
|
form.date = toJalaliPickerValue(record.date) || record.date;
|
||||||
|
form.entryTime = record.entryTime || '';
|
||||||
|
form.exitTime = record.exitTime || '';
|
||||||
|
form.note = record.note || '';
|
||||||
|
isFormDialogVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveForm = async () => {
|
||||||
|
if (!form.user) {
|
||||||
|
showError('لطفاً کارمند را انتخاب کنید.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isSubmitting.value = true;
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
user: form.user,
|
||||||
|
date: toGregorianIso(form.date) || form.date,
|
||||||
|
entryTime: form.entryTime,
|
||||||
|
exitTime: form.exitTime,
|
||||||
|
note: form.note
|
||||||
|
};
|
||||||
|
if (editingId.value) {
|
||||||
|
await employeeTimingApi.update(editingId.value, payload);
|
||||||
|
showSuccess('رکورد تردد با موفقیت بهروزرسانی شد.');
|
||||||
|
} else {
|
||||||
|
await employeeTimingApi.create(payload);
|
||||||
|
showSuccess('رکورد تردد جدید با موفقیت ثبت شد.');
|
||||||
|
}
|
||||||
|
isFormDialogVisible.value = false;
|
||||||
|
refresh();
|
||||||
|
loadSummary();
|
||||||
|
} catch (err) {
|
||||||
|
showError(err);
|
||||||
|
} finally {
|
||||||
|
isSubmitting.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDelete = (record) => {
|
||||||
|
confirm.require({
|
||||||
|
header: 'حذف رکورد تردد',
|
||||||
|
message: `آیا از حذف رکورد تردد ${record.user?.name || 'این کارمند'} در تاریخ ${formatJalali(record.date)} اطمینان دارید؟`,
|
||||||
|
icon: 'pi pi-exclamation-triangle',
|
||||||
|
acceptLabel: 'حذف',
|
||||||
|
rejectLabel: 'انصراف',
|
||||||
|
acceptClass: 'p-button-danger',
|
||||||
|
accept: async () => {
|
||||||
|
try {
|
||||||
|
await employeeTimingApi.delete(record._id || record.id);
|
||||||
|
showSuccess('رکورد تردد با موفقیت حذف شد.');
|
||||||
|
refresh();
|
||||||
|
loadSummary();
|
||||||
|
} catch (err) {
|
||||||
|
showError(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadUsers();
|
||||||
|
loadSummary();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.employee-timing-list-view {
|
||||||
|
min-height: 80vh;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,339 @@
|
|||||||
|
<!-- /src/views/financialReports/FinancialOverviewTab.vue -->
|
||||||
|
<!-- Institute-wide financial overview: KPI cards, income trends, monthly breakdown,
|
||||||
|
payment-status distribution, per-class leaderboard and a due-date driven cash-flow forecast. -->
|
||||||
|
<template>
|
||||||
|
<div class="financial-overview-tab">
|
||||||
|
<div v-if="isLoading && !analytics" class="text-center text-muted p-5">
|
||||||
|
<ProgressSpinner style="width: 40px; height: 40px;" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EmptyState v-else-if="!analytics" description="گزارش مالی مؤسسه در دسترس نیست" />
|
||||||
|
|
||||||
|
<div v-else class="flex flex-column gap-4">
|
||||||
|
<!-- ── KPI cards ─────────────────────────────────────────────────── -->
|
||||||
|
<div class="grid">
|
||||||
|
<div class="col-12 sm:col-6 lg:col-3">
|
||||||
|
<FinancialStatCard
|
||||||
|
label="کل وجوه وصولشده"
|
||||||
|
:value="analytics.overview.totalReceived"
|
||||||
|
icon="pi pi-check-circle"
|
||||||
|
bg-class="bg-green-100"
|
||||||
|
icon-class="text-green-600"
|
||||||
|
:hint="`از ${formatToman(analytics.overview.totalExpectedRevenue)} مورد انتظار`"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 sm:col-6 lg:col-3">
|
||||||
|
<FinancialStatCard
|
||||||
|
label="مطالبات معوق (سررسیدگذشته)"
|
||||||
|
:value="analytics.overview.overdueAmount"
|
||||||
|
icon="pi pi-exclamation-triangle"
|
||||||
|
bg-class="bg-red-100"
|
||||||
|
icon-class="text-red-600"
|
||||||
|
:hint="`${toPersianDigits(analytics.overview.overdueCount)} قسط معوق`"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 sm:col-6 lg:col-3">
|
||||||
|
<FinancialStatCard
|
||||||
|
label="در انتظار وصول (کل)"
|
||||||
|
:value="analytics.overview.totalPendingReceivables"
|
||||||
|
icon="pi pi-clock"
|
||||||
|
bg-class="bg-orange-100"
|
||||||
|
icon-class="text-orange-600"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 sm:col-6 lg:col-3">
|
||||||
|
<FinancialStatCard
|
||||||
|
label="سود خالص مؤسسه (کل دوران)"
|
||||||
|
:value="analytics.overview.netProfitAllTime"
|
||||||
|
icon="pi pi-chart-line"
|
||||||
|
:bg-class="analytics.overview.netProfitAllTime >= 0 ? 'bg-teal-100' : 'bg-red-100'"
|
||||||
|
:icon-class="analytics.overview.netProfitAllTime >= 0 ? 'text-teal-600' : 'text-red-600'"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 sm:col-6 lg:col-3">
|
||||||
|
<FinancialStatCard
|
||||||
|
label="میانگین درآمد هر جلسه برگزارشده"
|
||||||
|
:value="analytics.overview.avgIncomePerHeldSession"
|
||||||
|
icon="pi pi-calculator"
|
||||||
|
bg-class="bg-blue-100"
|
||||||
|
icon-class="text-blue-600"
|
||||||
|
hint="بر اساس وجوه وصولشده"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 sm:col-6 lg:col-3">
|
||||||
|
<FinancialStatCard
|
||||||
|
label="میانگین درآمد مورد انتظار هر جلسه"
|
||||||
|
:value="analytics.overview.avgExpectedIncomePerPlannedSession"
|
||||||
|
icon="pi pi-percentage"
|
||||||
|
bg-class="bg-indigo-100"
|
||||||
|
icon-class="text-indigo-600"
|
||||||
|
hint="بر اساس شهریههای ثبتشده"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 sm:col-6 lg:col-3">
|
||||||
|
<FinancialStatCard
|
||||||
|
label="جلسات برگزارشده / برنامهریزیشده"
|
||||||
|
format="text"
|
||||||
|
:display-value="`${toPersianDigits(analytics.overview.totalSessionsHeld)} / ${toPersianDigits(analytics.overview.totalSessionsPlanned)}`"
|
||||||
|
icon="pi pi-calendar-plus"
|
||||||
|
bg-class="bg-purple-100"
|
||||||
|
icon-class="text-purple-600"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 sm:col-6 lg:col-3">
|
||||||
|
<FinancialStatCard
|
||||||
|
label="کلاسهای فعال / کل کلاسها"
|
||||||
|
format="text"
|
||||||
|
:display-value="`${toPersianDigits(analytics.overview.totalActiveClasses)} / ${toPersianDigits(analytics.overview.totalClasses)}`"
|
||||||
|
icon="pi pi-building"
|
||||||
|
bg-class="bg-cyan-100"
|
||||||
|
icon-class="text-cyan-600"
|
||||||
|
:hint="`${toPersianDigits(analytics.overview.totalEnrollments)} ثبتنام فعال`"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Income trend (daily/weekly, cash vs. session-based accrual) ──── -->
|
||||||
|
<div class="surface-card p-4 border-round-xl border-1 border-color shadow-sm">
|
||||||
|
<div class="flex flex-column md:flex-row align-items-start md:align-items-center justify-content-between gap-3 mb-4">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-base font-bold text-color m-0">روند درآمد بر اساس جلسات</h3>
|
||||||
|
<p class="text-xs text-muted m-0 mt-1">مقایسه وجوه نقداً دریافتی با درآمد تعهدی حاصل از جلسات برگزارشده</p>
|
||||||
|
</div>
|
||||||
|
<SelectButton
|
||||||
|
v-model="granularity"
|
||||||
|
:options="granularityOptions"
|
||||||
|
optionLabel="label"
|
||||||
|
optionValue="value"
|
||||||
|
:allowEmpty="false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<IncomeTrendChart :labels="trendLabels" :received="trendReceived" :session-income="trendSessionIncome" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Monthly breakdown ─────────────────────────────────────────── -->
|
||||||
|
<div class="surface-card p-4 border-round-xl border-1 border-color shadow-sm">
|
||||||
|
<div class="flex align-items-center justify-content-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-base font-bold text-color m-0">گزارش کامل درآمد مؤسسه ({{ toPersianDigits(analytics.monthly.length) }} ماه اخیر)</h3>
|
||||||
|
<p class="text-xs text-muted m-0 mt-1">دریافتی، سهم اساتید، هزینههای عمومی و سود خالص در هر ماه</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<MonthlyBreakdownChart
|
||||||
|
:labels="monthlyLabels"
|
||||||
|
:received="monthlyReceived"
|
||||||
|
:professor-payouts="monthlyPayouts"
|
||||||
|
:general-expenses="monthlyExpenses"
|
||||||
|
:net-profit="monthlyNetProfit"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Payment status + Forecast ────────────────────────────────── -->
|
||||||
|
<div class="grid">
|
||||||
|
<div class="col-12 lg:col-5">
|
||||||
|
<div class="surface-card p-4 border-round-xl border-1 border-color shadow-sm h-full">
|
||||||
|
<h3 class="text-base font-bold text-color m-0 mb-4">وضعیت مطالبات (بر اساس مبلغ)</h3>
|
||||||
|
<PaymentStatusChart :breakdown="analytics.paymentStatusBreakdown" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 lg:col-7">
|
||||||
|
<div class="surface-card p-4 border-round-xl border-1 border-color shadow-sm h-full">
|
||||||
|
<h3 class="text-base font-bold text-color m-0 mb-1">پیشبینی وصولی بر اساس سررسید اقساط</h3>
|
||||||
|
<p class="text-xs text-muted m-0 mb-4">۸ هفته پیشرو — بر اساس تاریخ سررسید تراکنشهای در انتظار پرداخت</p>
|
||||||
|
<div class="grid mb-3">
|
||||||
|
<div class="col-4">
|
||||||
|
<div class="surface-100 border-round p-2 text-center">
|
||||||
|
<div class="text-xs text-muted mb-1">۷ روز آینده</div>
|
||||||
|
<div class="font-bold text-sm text-color">{{ formatToman(analytics.forecast.next7DaysAmount) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-4">
|
||||||
|
<div class="surface-100 border-round p-2 text-center">
|
||||||
|
<div class="text-xs text-muted mb-1">۳۰ روز آینده</div>
|
||||||
|
<div class="font-bold text-sm text-color">{{ formatToman(analytics.forecast.next30DaysAmount) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-4">
|
||||||
|
<div class="surface-100 border-round p-2 text-center">
|
||||||
|
<div class="text-xs text-muted mb-1">۶۰ روز آینده</div>
|
||||||
|
<div class="font-bold text-sm text-color">{{ formatToman(analytics.forecast.next60DaysAmount) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ForecastChart :labels="forecastLabels" :amounts="forecastAmounts" :counts="forecastCounts" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Per-class leaderboard: chart + table ─────────────────────── -->
|
||||||
|
<div class="surface-card p-4 border-round-xl border-1 border-color shadow-sm">
|
||||||
|
<h3 class="text-base font-bold text-color m-0 mb-4">عملکرد مالی به تفکیک کلاس (درآمد هر جلسه)</h3>
|
||||||
|
<IncomeByClassChart :rows="analytics.incomeByClass" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 class="text-base font-bold text-color mb-3">جدول کامل درآمد به تفکیک کلاس</h3>
|
||||||
|
<DataTable
|
||||||
|
:value="analytics.incomeByClass"
|
||||||
|
class="p-datatable-sm text-sm"
|
||||||
|
emptyMessage="هنوز کلاسی با گزارش مالی ثبت نشده است"
|
||||||
|
:paginator="analytics.incomeByClass.length > 10"
|
||||||
|
:rows="10"
|
||||||
|
>
|
||||||
|
<Column header="کلاس">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<span class="font-semibold text-color">{{ data.className }}</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column header="استاد">
|
||||||
|
<template #body="{ data }">{{ data.professorName }}</template>
|
||||||
|
</Column>
|
||||||
|
<Column header="دانشجویان">
|
||||||
|
<template #body="{ data }">{{ toPersianDigits(data.studentsCount) }}</template>
|
||||||
|
</Column>
|
||||||
|
<Column header="جلسات (برگزار/برنامه)">
|
||||||
|
<template #body="{ data }">{{ toPersianDigits(data.sessionsHeld) }} / {{ toPersianDigits(data.sessionsPlanned) }}</template>
|
||||||
|
</Column>
|
||||||
|
<Column header="وصولشده">
|
||||||
|
<template #body="{ data }">{{ formatToman(data.actualReceivedRevenue) }}</template>
|
||||||
|
</Column>
|
||||||
|
<Column header="معوق">
|
||||||
|
<template #body="{ data }">{{ formatToman(data.pendingReceivables) }}</template>
|
||||||
|
</Column>
|
||||||
|
<Column header="درآمد هر جلسه (واقعی)">
|
||||||
|
<template #body="{ data }"><span class="font-semibold text-green-600">{{ formatToman(data.incomePerSessionActual) }}</span></template>
|
||||||
|
</Column>
|
||||||
|
<Column header="درآمد هر جلسه (مورد انتظار)">
|
||||||
|
<template #body="{ data }">{{ formatToman(data.incomePerSessionExpected) }}</template>
|
||||||
|
</Column>
|
||||||
|
</DataTable>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Upcoming / overdue due-date schedule ─────────────────────── -->
|
||||||
|
<div>
|
||||||
|
<div class="flex align-items-center justify-content-between mb-3">
|
||||||
|
<h3 class="text-base font-bold text-color m-0">جدول سررسید پرداختها (اقساط دانشجویان)</h3>
|
||||||
|
<Tag :value="`${toPersianDigits(analytics.upcomingDue.length)} قسط`" severity="secondary" />
|
||||||
|
</div>
|
||||||
|
<DataTable
|
||||||
|
:value="analytics.upcomingDue"
|
||||||
|
class="p-datatable-sm text-sm"
|
||||||
|
emptyMessage="قسط در انتظار پرداختی یافت نشد"
|
||||||
|
:paginator="analytics.upcomingDue.length > 10"
|
||||||
|
:rows="10"
|
||||||
|
>
|
||||||
|
<Column header="دانشجو">
|
||||||
|
<template #body="{ data }">{{ data.studentName }}</template>
|
||||||
|
</Column>
|
||||||
|
<Column header="کلاس">
|
||||||
|
<template #body="{ data }">{{ data.className }}</template>
|
||||||
|
</Column>
|
||||||
|
<Column header="مبلغ قسط">
|
||||||
|
<template #body="{ data }">{{ formatToman(data.amount) }}</template>
|
||||||
|
</Column>
|
||||||
|
<Column header="تاریخ سررسید">
|
||||||
|
<template #body="{ data }">{{ formatJalali(data.dueDate) }}</template>
|
||||||
|
</Column>
|
||||||
|
<Column header="وضعیت">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<Tag
|
||||||
|
:value="dueStatusLabel(data)"
|
||||||
|
:severity="dueStatusSeverity(data)"
|
||||||
|
class="text-xs font-semibold"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
</DataTable>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted } from 'vue';
|
||||||
|
import { usePersianDate } from '@/composables/usePersianDate';
|
||||||
|
import { useToast } from '@/composables/useToast';
|
||||||
|
import { financialReportApi } from '@/api/financialReportApi';
|
||||||
|
import EmptyState from '@/components/common/EmptyState.vue';
|
||||||
|
import FinancialStatCard from '@/components/financialReports/FinancialStatCard.vue';
|
||||||
|
import IncomeTrendChart from '@/components/financialReports/charts/IncomeTrendChart.vue';
|
||||||
|
import MonthlyBreakdownChart from '@/components/financialReports/charts/MonthlyBreakdownChart.vue';
|
||||||
|
import PaymentStatusChart from '@/components/financialReports/charts/PaymentStatusChart.vue';
|
||||||
|
import IncomeByClassChart from '@/components/financialReports/charts/IncomeByClassChart.vue';
|
||||||
|
import ForecastChart from '@/components/financialReports/charts/ForecastChart.vue';
|
||||||
|
import ProgressSpinner from 'primevue/progressspinner';
|
||||||
|
import SelectButton from 'primevue/selectbutton';
|
||||||
|
import DataTable from 'primevue/datatable';
|
||||||
|
import Column from 'primevue/column';
|
||||||
|
import Tag from 'primevue/tag';
|
||||||
|
|
||||||
|
const { toPersianDigits, formatJalali } = usePersianDate();
|
||||||
|
const { showError } = useToast();
|
||||||
|
|
||||||
|
const formatToman = (value) => `${toPersianDigits(Math.round(value || 0).toLocaleString())} تومان`;
|
||||||
|
|
||||||
|
const analytics = ref(null);
|
||||||
|
const isLoading = ref(false);
|
||||||
|
|
||||||
|
const granularity = ref('daily');
|
||||||
|
const granularityOptions = [
|
||||||
|
{ label: 'روزانه', value: 'daily' },
|
||||||
|
{ label: 'هفتگی', value: 'weekly' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const trendLabels = computed(() => {
|
||||||
|
if (!analytics.value) return [];
|
||||||
|
const rows = granularity.value === 'daily' ? analytics.value.daily : analytics.value.weekly;
|
||||||
|
const dateField = granularity.value === 'daily' ? 'date' : 'weekStart';
|
||||||
|
return rows.map((r) => formatJalali(r[dateField], 'jD jMMMM'));
|
||||||
|
});
|
||||||
|
const trendReceived = computed(() => {
|
||||||
|
if (!analytics.value) return [];
|
||||||
|
const rows = granularity.value === 'daily' ? analytics.value.daily : analytics.value.weekly;
|
||||||
|
return rows.map((r) => r.received);
|
||||||
|
});
|
||||||
|
const trendSessionIncome = computed(() => {
|
||||||
|
if (!analytics.value) return [];
|
||||||
|
const rows = granularity.value === 'daily' ? analytics.value.daily : analytics.value.weekly;
|
||||||
|
return rows.map((r) => r.sessionIncome);
|
||||||
|
});
|
||||||
|
|
||||||
|
const monthlyLabels = computed(() => (analytics.value ? analytics.value.monthly.map((m) => formatJalali(m.monthStart, 'jMMMM jYY')) : []));
|
||||||
|
const monthlyReceived = computed(() => (analytics.value ? analytics.value.monthly.map((m) => m.received) : []));
|
||||||
|
const monthlyPayouts = computed(() => (analytics.value ? analytics.value.monthly.map((m) => m.professorPayouts) : []));
|
||||||
|
const monthlyExpenses = computed(() => (analytics.value ? analytics.value.monthly.map((m) => m.generalExpenses) : []));
|
||||||
|
const monthlyNetProfit = computed(() => (analytics.value ? analytics.value.monthly.map((m) => m.netProfit) : []));
|
||||||
|
|
||||||
|
const forecastLabels = computed(() => (analytics.value ? analytics.value.forecast.buckets.map((b) => formatJalali(b.weekStart, 'jD jMMMM')) : []));
|
||||||
|
const forecastAmounts = computed(() => (analytics.value ? analytics.value.forecast.buckets.map((b) => b.expectedAmount) : []));
|
||||||
|
const forecastCounts = computed(() => (analytics.value ? analytics.value.forecast.buckets.map((b) => b.count) : []));
|
||||||
|
|
||||||
|
const dueStatusLabel = (row) => {
|
||||||
|
if (row.isOverdue) return `${toPersianDigits(Math.abs(row.daysUntilDue))} روز معوق`;
|
||||||
|
if (row.daysUntilDue === 0) return 'سررسید امروز';
|
||||||
|
if (row.daysUntilDue <= 7) return `${toPersianDigits(row.daysUntilDue)} روز دیگر`;
|
||||||
|
return `${toPersianDigits(row.daysUntilDue)} روز دیگر`;
|
||||||
|
};
|
||||||
|
const dueStatusSeverity = (row) => {
|
||||||
|
if (row.isOverdue) return 'danger';
|
||||||
|
if (row.daysUntilDue <= 7) return 'warn';
|
||||||
|
return 'info';
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadAnalytics = async () => {
|
||||||
|
isLoading.value = true;
|
||||||
|
try {
|
||||||
|
const res = await financialReportApi.getAnalytics();
|
||||||
|
analytics.value = res.data || res;
|
||||||
|
} catch (err) {
|
||||||
|
showError(err);
|
||||||
|
analytics.value = null;
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(loadAnalytics);
|
||||||
|
|
||||||
|
defineExpose({ reload: loadAnalytics });
|
||||||
|
</script>
|
||||||
@@ -7,14 +7,22 @@
|
|||||||
</PermissionGate>
|
</PermissionGate>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
<Tabs value="0" class="surface-card border-round border-1 border-color shadow-sm">
|
<Tabs v-model:value="activeTab" class="surface-card border-round border-1 border-color shadow-sm">
|
||||||
<TabList>
|
<TabList>
|
||||||
<Tab value="0">گزارش کلاسها</Tab>
|
<Tab value="0">نمای کلی و نمودارها</Tab>
|
||||||
<Tab value="1">گزارش بازه زمانی / ماهانه</Tab>
|
<Tab value="1">گزارش کلاسها</Tab>
|
||||||
|
<Tab value="2">گزارش بازه زمانی / ماهانه</Tab>
|
||||||
</TabList>
|
</TabList>
|
||||||
<TabPanels>
|
<TabPanels>
|
||||||
<!-- ── Per-Class (Lifetime) Report ────────────────────────────────── -->
|
<!-- ── Institute-wide Overview: charts, trends & forecasts ─────────── -->
|
||||||
<TabPanel value="0">
|
<TabPanel value="0">
|
||||||
|
<div class="p-3">
|
||||||
|
<FinancialOverviewTab />
|
||||||
|
</div>
|
||||||
|
</TabPanel>
|
||||||
|
|
||||||
|
<!-- ── Per-Class (Lifetime) Report ────────────────────────────────── -->
|
||||||
|
<TabPanel value="1">
|
||||||
<div class="p-3">
|
<div class="p-3">
|
||||||
<div class="flex flex-column md:flex-row gap-2 mb-4 align-items-end">
|
<div class="flex flex-column md:flex-row gap-2 mb-4 align-items-end">
|
||||||
<div class="flex flex-column gap-2 flex-grow-1 md:max-w-25rem">
|
<div class="flex flex-column gap-2 flex-grow-1 md:max-w-25rem">
|
||||||
@@ -166,7 +174,7 @@
|
|||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
|
||||||
<!-- ── Date-Range / Monthly Report ─────────────────────────────────── -->
|
<!-- ── Date-Range / Monthly Report ─────────────────────────────────── -->
|
||||||
<TabPanel value="1">
|
<TabPanel value="2">
|
||||||
<div class="p-3">
|
<div class="p-3">
|
||||||
<div class="flex flex-column md:flex-row gap-3 mb-4 align-items-end">
|
<div class="flex flex-column md:flex-row gap-3 mb-4 align-items-end">
|
||||||
<div class="flex flex-column gap-2">
|
<div class="flex flex-column gap-2">
|
||||||
@@ -326,6 +334,7 @@ import TableSkeleton from '@/components/common/TableSkeleton.vue';
|
|||||||
import StatusTag from '@/components/common/StatusTag.vue';
|
import StatusTag from '@/components/common/StatusTag.vue';
|
||||||
import PermissionGate from '@/components/common/PermissionGate.vue';
|
import PermissionGate from '@/components/common/PermissionGate.vue';
|
||||||
import FinancialStatCard from '@/components/financialReports/FinancialStatCard.vue';
|
import FinancialStatCard from '@/components/financialReports/FinancialStatCard.vue';
|
||||||
|
import FinancialOverviewTab from './FinancialOverviewTab.vue';
|
||||||
import Button from 'primevue/button';
|
import Button from 'primevue/button';
|
||||||
import Dropdown from 'primevue/select';
|
import Dropdown from 'primevue/select';
|
||||||
import DataTable from 'primevue/datatable';
|
import DataTable from 'primevue/datatable';
|
||||||
@@ -343,6 +352,8 @@ const route = useRoute();
|
|||||||
const { toPersianDigits, formatJalali, toGregorianIso, toJalaliPickerValue, getTodayJalali, getStartOfJalaliMonth } = usePersianDate();
|
const { toPersianDigits, formatJalali, toGregorianIso, toJalaliPickerValue, getTodayJalali, getStartOfJalaliMonth } = usePersianDate();
|
||||||
const { showError } = useToast();
|
const { showError } = useToast();
|
||||||
|
|
||||||
|
const activeTab = ref('0');
|
||||||
|
|
||||||
const formatToman = (value) => `${toPersianDigits(Math.round(value || 0).toLocaleString())} تومان`;
|
const formatToman = (value) => `${toPersianDigits(Math.round(value || 0).toLocaleString())} تومان`;
|
||||||
|
|
||||||
// ── Per-class report ──────────────────────────────────────────────────────
|
// ── Per-class report ──────────────────────────────────────────────────────
|
||||||
@@ -441,6 +452,7 @@ onMounted(async () => {
|
|||||||
await loadClasses();
|
await loadClasses();
|
||||||
setThisMonth();
|
setThisMonth();
|
||||||
if (route.query.classId) {
|
if (route.query.classId) {
|
||||||
|
activeTab.value = '1';
|
||||||
selectedClassId.value = String(route.query.classId);
|
selectedClassId.value = String(route.query.classId);
|
||||||
loadClassReport();
|
loadClassReport();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,566 @@
|
|||||||
|
<!-- /src/views/notifications/NotificationTemplatesView.vue -->
|
||||||
|
<template>
|
||||||
|
<div class="notification-templates-view w-full max-w-5xl mx-auto">
|
||||||
|
<PageHeader
|
||||||
|
title="قالبهای اعلان"
|
||||||
|
subtitle="مدیریت متن، شناسهها و متغیرهای قالبهای پیامک، ایمیل و بازوی بله"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Tabs value="0" class="surface-card border-round-xl border-1 border-color shadow-sm">
|
||||||
|
<TabList :scrollable="true">
|
||||||
|
<Tab value="0">
|
||||||
|
<div class="flex align-items-center gap-2">
|
||||||
|
<i class="pi pi-mobile text-primary"></i>
|
||||||
|
<span>پیامک</span>
|
||||||
|
</div>
|
||||||
|
</Tab>
|
||||||
|
<Tab value="1">
|
||||||
|
<div class="flex align-items-center gap-2">
|
||||||
|
<i class="pi pi-envelope text-color-secondary"></i>
|
||||||
|
<span>ایمیل</span>
|
||||||
|
</div>
|
||||||
|
</Tab>
|
||||||
|
<Tab value="2">
|
||||||
|
<div class="flex align-items-center gap-2">
|
||||||
|
<i class="pi pi-comments text-color-secondary"></i>
|
||||||
|
<span>بازوی بله</span>
|
||||||
|
</div>
|
||||||
|
</Tab>
|
||||||
|
</TabList>
|
||||||
|
|
||||||
|
<TabPanels>
|
||||||
|
<!-- Tab 0: SMS Templates -->
|
||||||
|
<TabPanel value="0">
|
||||||
|
<div class="p-4 sm:p-5">
|
||||||
|
<div class="flex align-items-start gap-3 mb-4">
|
||||||
|
<div class="w-3rem h-3rem border-round-xl flex align-items-center justify-content-center flex-shrink-0 bg-primary-light text-primary">
|
||||||
|
<i class="pi pi-send text-xl"></i>
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<h2 class="text-lg font-bold text-color m-0 mb-1">قالبهای پیامک (sms.ir)</h2>
|
||||||
|
<p class="text-sm text-muted m-0 line-height-3">
|
||||||
|
شناسه قالب، متن و متغیرهای ارسالی پنل sms.ir را در این بخش تنظیم کنید. هر متغیر با فرمت <code dir="ltr">#NAME#</code> در متن قالب جایگذاری و هنگام ارسال جایگزین میشود.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="isLoading" class="flex align-items-center gap-2 text-muted text-sm py-4">
|
||||||
|
<i class="pi pi-spin pi-spinner"></i>
|
||||||
|
<span>در حال بارگذاری قالبهای پیامک…</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="flex flex-column gap-4">
|
||||||
|
<div
|
||||||
|
v-for="template in smsTemplates"
|
||||||
|
:key="template.key"
|
||||||
|
class="p-4 border-round-xl surface-ground border-1 border-color flex flex-column gap-3 transition-all transition-duration-200"
|
||||||
|
:class="{ 'opacity-80': templateForm[template.key] && !templateForm[template.key].enabled }"
|
||||||
|
>
|
||||||
|
<template v-if="templateForm[template.key]">
|
||||||
|
<!-- Template Header -->
|
||||||
|
<div class="flex flex-column sm:flex-row sm:align-items-center justify-content-between gap-3 border-bottom-1 border-color pb-3">
|
||||||
|
<div class="flex align-items-center gap-3">
|
||||||
|
<div
|
||||||
|
class="w-2.5rem h-2.5rem border-round-lg flex align-items-center justify-content-center flex-shrink-0"
|
||||||
|
:class="templateForm[template.key].enabled ? 'bg-primary-light text-primary' : 'surface-200 text-muted'"
|
||||||
|
>
|
||||||
|
<i class="pi pi-bookmark text-base"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="flex align-items-center gap-2 flex-wrap">
|
||||||
|
<label class="font-bold text-base text-color cursor-pointer" :for="`sms-toggle-${template.key}`">
|
||||||
|
{{ template.label }}
|
||||||
|
</label>
|
||||||
|
<Tag
|
||||||
|
v-if="template.category || templateForm[template.key].category"
|
||||||
|
:value="template.category || templateForm[template.key].category"
|
||||||
|
severity="info"
|
||||||
|
class="text-xs font-semibold"
|
||||||
|
/>
|
||||||
|
<Tag
|
||||||
|
:value="templateForm[template.key].enabled ? 'فعال' : 'غیرفعال'"
|
||||||
|
:severity="templateForm[template.key].enabled ? 'success' : 'secondary'"
|
||||||
|
class="text-xs font-semibold"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs text-muted font-mono block mt-1">{{ template.key }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex align-items-center gap-3 flex-shrink-0">
|
||||||
|
<span class="text-xs font-semibold text-color">
|
||||||
|
{{ templateForm[template.key].enabled ? 'ارسال پیامک فعال' : 'ارسال غیرفعال' }}
|
||||||
|
</span>
|
||||||
|
<InputSwitch
|
||||||
|
:inputId="`sms-toggle-${template.key}`"
|
||||||
|
v-model="templateForm[template.key].enabled"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="!templateForm[template.key].enabled"
|
||||||
|
class="p-2 px-3 border-round surface-card border-1 border-dashed border-color text-xs text-muted flex align-items-center gap-2"
|
||||||
|
>
|
||||||
|
<i class="pi pi-info-circle text-orange-500 flex-shrink-0"></i>
|
||||||
|
<span>ارسال این نوع پیامک غیرفعال است و با وقوع این رویداد پیامکی ارسال نخواهد شد.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Template ID Input -->
|
||||||
|
<div class="flex flex-column sm:flex-row sm:align-items-center gap-2">
|
||||||
|
<label
|
||||||
|
class="text-sm font-semibold text-color sm:w-10rem flex-shrink-0"
|
||||||
|
:for="`sms-template-${template.key}`"
|
||||||
|
>
|
||||||
|
شناسه قالب sms.ir:
|
||||||
|
</label>
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<InputText
|
||||||
|
:id="`sms-template-${template.key}`"
|
||||||
|
v-model.trim="templateForm[template.key].templateId"
|
||||||
|
class="w-full text-sm font-mono"
|
||||||
|
dir="ltr"
|
||||||
|
inputmode="numeric"
|
||||||
|
placeholder="مثلاً: 720661"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Template Text Area -->
|
||||||
|
<div class="flex flex-column gap-1">
|
||||||
|
<div class="flex align-items-center justify-content-between">
|
||||||
|
<label
|
||||||
|
class="text-sm font-semibold text-color"
|
||||||
|
:for="`sms-text-${template.key}`"
|
||||||
|
>
|
||||||
|
متن قالب پیامک:
|
||||||
|
</label>
|
||||||
|
<span class="text-xs text-muted">
|
||||||
|
متغیرها را به فرمت <code dir="ltr" class="text-primary font-bold">#NAME#</code> بنویسید
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Textarea
|
||||||
|
:id="`sms-text-${template.key}`"
|
||||||
|
v-model="templateForm[template.key].text"
|
||||||
|
rows="3"
|
||||||
|
class="w-full text-sm line-height-3 surface-card"
|
||||||
|
autoResize
|
||||||
|
placeholder="متن کامل قالب پیامک را وارد کنید..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Variables Management -->
|
||||||
|
<div class="flex flex-column gap-3 pt-2">
|
||||||
|
<div class="flex align-items-center justify-content-between">
|
||||||
|
<div>
|
||||||
|
<span class="text-sm font-semibold text-color">متغیرهای ارسالی در قالب</span>
|
||||||
|
<span class="text-xs text-muted block mt-1">متغیرهایی که در پنل sms.ir در متن قالب قرار دادهاید</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
label="افزودن متغیر"
|
||||||
|
icon="pi pi-plus"
|
||||||
|
size="small"
|
||||||
|
outlined
|
||||||
|
class="text-xs font-semibold"
|
||||||
|
@click="addVariable(template.key)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="!templateForm[template.key]?.variables || templateForm[template.key]?.variables.length === 0"
|
||||||
|
class="p-3 text-center border-1 border-dashed border-round surface-card text-muted text-xs line-height-3"
|
||||||
|
>
|
||||||
|
هیچ متغیری برای این قالب تعریف نشده است. پیامک بدون متغیر ارسال خواهد شد.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="flex flex-column gap-2">
|
||||||
|
<div
|
||||||
|
v-for="(variable, vIdx) in templateForm[template.key].variables"
|
||||||
|
:key="variable.id || vIdx"
|
||||||
|
class="flex flex-column md:flex-row md:align-items-center gap-3 p-3 border-round surface-card border-1 border-color"
|
||||||
|
>
|
||||||
|
<div class="flex flex-column gap-1 md:w-16rem flex-shrink-0">
|
||||||
|
<span class="text-xs text-muted font-semibold">مقدار داده در سیستم:</span>
|
||||||
|
<Select
|
||||||
|
v-model="variable.slot"
|
||||||
|
:options="getAvailableSlots(template.key)"
|
||||||
|
optionLabel="label"
|
||||||
|
optionValue="value"
|
||||||
|
placeholder="انتخاب مقدار داده"
|
||||||
|
class="w-full text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-grow-1 flex flex-column gap-1">
|
||||||
|
<span class="text-xs text-muted font-semibold">نام متغیر در sms.ir:</span>
|
||||||
|
<div class="flex align-items-center gap-2">
|
||||||
|
<InputText
|
||||||
|
v-model.trim="variable.name"
|
||||||
|
class="w-full text-sm font-mono"
|
||||||
|
dir="ltr"
|
||||||
|
:placeholder="getSlotDefaultName(template.key, variable.slot) || 'نام متغیر در sms.ir'"
|
||||||
|
autocomplete="off"
|
||||||
|
/>
|
||||||
|
<Tag
|
||||||
|
:value="`#${variable.name || getSlotDefaultName(template.key, variable.slot) || '...'}#`"
|
||||||
|
severity="secondary"
|
||||||
|
class="text-xs flex-shrink-0 font-mono"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
icon="pi pi-trash"
|
||||||
|
severity="danger"
|
||||||
|
text
|
||||||
|
rounded
|
||||||
|
size="small"
|
||||||
|
class="p-button-sm flex-shrink-0"
|
||||||
|
title="حذف متغیر"
|
||||||
|
@click="removeVariable(template.key, vIdx)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Save Templates Button -->
|
||||||
|
<div class="flex justify-content-end pt-2">
|
||||||
|
<Button
|
||||||
|
label="ذخیره قالبهای پیامک"
|
||||||
|
icon="pi pi-save"
|
||||||
|
class="font-bold"
|
||||||
|
:loading="isSaving"
|
||||||
|
@click="saveSmsTemplates"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TabPanel>
|
||||||
|
|
||||||
|
<!-- Tab 1: Email Templates Placeholder -->
|
||||||
|
<TabPanel value="1">
|
||||||
|
<div class="p-5 text-center flex flex-column align-items-center justify-content-center gap-3">
|
||||||
|
<div class="w-4rem h-4rem border-round-2xl flex align-items-center justify-content-center bg-blue-50 text-blue-500">
|
||||||
|
<i class="pi pi-envelope text-3xl"></i>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-lg font-bold text-color m-0">قالبهای اعلان ایمیل</h3>
|
||||||
|
<p class="text-sm text-muted m-0 max-w-28rem line-height-3">
|
||||||
|
امکان ویرایش و سفارشیسازی قالبهای HTML ایمیل برای رویدادهای مختلف سیستم در بهروزرسانیهای آینده فعال خواهد شد.
|
||||||
|
</p>
|
||||||
|
<Tag value="بهزودی" severity="info" class="text-xs font-semibold px-3 py-1" />
|
||||||
|
</div>
|
||||||
|
</TabPanel>
|
||||||
|
|
||||||
|
<!-- Tab 2: Bale Bot Templates Placeholder -->
|
||||||
|
<TabPanel value="2">
|
||||||
|
<div class="p-5 text-center flex flex-column align-items-center justify-content-center gap-3">
|
||||||
|
<div class="w-4rem h-4rem border-round-2xl flex align-items-center justify-content-center bg-green-50 text-green-600">
|
||||||
|
<i class="pi pi-comments text-3xl"></i>
|
||||||
|
</div>
|
||||||
|
<h3 class="text-lg font-bold text-color m-0">قالبهای بازوی بله</h3>
|
||||||
|
<p class="text-sm text-muted m-0 max-w-28rem line-height-3">
|
||||||
|
امکان تنظیم و شخصیسازی پیامها و کلیدهای شیشهای بازوی پیامرسان بله در بهروزرسانیهای آینده فعال خواهد شد.
|
||||||
|
</p>
|
||||||
|
<Tag value="بهزودی" severity="success" class="text-xs font-semibold px-3 py-1" />
|
||||||
|
</div>
|
||||||
|
</TabPanel>
|
||||||
|
</TabPanels>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { onMounted, ref } from 'vue';
|
||||||
|
import Tabs from 'primevue/tabs';
|
||||||
|
import TabList from 'primevue/tablist';
|
||||||
|
import Tab from 'primevue/tab';
|
||||||
|
import TabPanels from 'primevue/tabpanels';
|
||||||
|
import TabPanel from 'primevue/tabpanel';
|
||||||
|
import Button from 'primevue/button';
|
||||||
|
import InputSwitch from 'primevue/toggleswitch';
|
||||||
|
import InputText from 'primevue/inputtext';
|
||||||
|
import Textarea from 'primevue/textarea';
|
||||||
|
import Select from 'primevue/select';
|
||||||
|
import Tag from 'primevue/tag';
|
||||||
|
import PageHeader from '@/components/common/PageHeader.vue';
|
||||||
|
import { settingsApi } from '@/api/settingsApi';
|
||||||
|
import { useToast } from '@/composables/useToast';
|
||||||
|
|
||||||
|
const { showSuccess, showError } = useToast();
|
||||||
|
|
||||||
|
const isLoading = ref(true);
|
||||||
|
const isSaving = ref(false);
|
||||||
|
const smsTemplates = ref([]);
|
||||||
|
const templateForm = ref({});
|
||||||
|
|
||||||
|
const TEMPLATE_SLOT_DEFS = {
|
||||||
|
sessionHolding: [
|
||||||
|
{ value: 'fullName', label: 'نام کارآموز', defaultName: 'FULLNAME' },
|
||||||
|
{ value: 'topic', label: 'موضوع جلسه', defaultName: 'TOPIC' },
|
||||||
|
{ value: 'className', label: 'نام کلاس', defaultName: 'CLASSNAME' },
|
||||||
|
{ value: 'sessionDate', label: 'تاریخ جلسه', defaultName: 'SESSIONDATE' },
|
||||||
|
{ value: 'classTime', label: 'ساعت کلاس', defaultName: 'CLASSTIME' },
|
||||||
|
{ value: 'courseName', label: 'نام دوره', defaultName: 'courseName' },
|
||||||
|
{ value: 'classCode', label: 'کد کلاس', defaultName: 'classCode' },
|
||||||
|
{ value: 'place', label: 'مکان', defaultName: 'place' },
|
||||||
|
{ value: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' }
|
||||||
|
],
|
||||||
|
certificateIssued: [
|
||||||
|
{ value: 'fullName', label: 'نام کارآموز', defaultName: 'FULLNAME' },
|
||||||
|
{ value: 'certificateTitle', label: 'عنوان گواهینامه', defaultName: 'CERTIFICATETITLE' },
|
||||||
|
{ value: 'courseName', label: 'نام دوره', defaultName: 'COURSENAME' },
|
||||||
|
{ value: 'certificateCode', label: 'کد گواهینامه', defaultName: 'certificateCode' },
|
||||||
|
{ value: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' }
|
||||||
|
],
|
||||||
|
sessionCancelled: [
|
||||||
|
{ value: 'fullName', label: 'نام کارآموز', defaultName: 'FULLNAME' },
|
||||||
|
{ value: 'topic', label: 'موضوع جلسه', defaultName: 'TOPIC' },
|
||||||
|
{ value: 'className', label: 'نام کلاس', defaultName: 'CLASSNAME' },
|
||||||
|
{ value: 'sessionDate', label: 'تاریخ جلسه', defaultName: 'SESSIONDATE' },
|
||||||
|
{ value: 'reason', label: 'دلیل لغو', defaultName: 'reason' },
|
||||||
|
{ value: 'classCode', label: 'کد کلاس', defaultName: 'classCode' },
|
||||||
|
{ value: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' }
|
||||||
|
],
|
||||||
|
transactionRecorded: [
|
||||||
|
{ value: 'fullName', label: 'نام کارآموز', defaultName: 'FULLNAME' },
|
||||||
|
{ value: 'transactionCode', label: 'کد تراکنش', defaultName: 'TRANSACTIONCODE' },
|
||||||
|
{ value: 'amount', label: 'مبلغ', defaultName: 'AMOUNT' },
|
||||||
|
{ value: 'invoiceCode', label: 'کد صورتحساب', defaultName: 'INVOICECODE' },
|
||||||
|
{ value: 'receiptNumber', label: 'شماره رسید', defaultName: 'RECEIPTNUMBER' },
|
||||||
|
{ value: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' }
|
||||||
|
],
|
||||||
|
paymentReminder: [
|
||||||
|
{ value: 'fullName', label: 'نام کارآموز', defaultName: 'FULLNAME' },
|
||||||
|
{ value: 'amount', label: 'مبلغ باقیمانده', defaultName: 'AMOUNT' },
|
||||||
|
{ value: 'dueDate', label: 'تاریخ سررسید', defaultName: 'DUEDATE' },
|
||||||
|
{ value: 'invoiceCode', label: 'کد صورتحساب', defaultName: 'INVOICECODE' },
|
||||||
|
{ value: 'course', label: 'نام دوره', defaultName: 'COURSE' },
|
||||||
|
{ value: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' }
|
||||||
|
],
|
||||||
|
paymentStatusChanged: [
|
||||||
|
{ value: 'fullName', label: 'نام کارآموز', defaultName: 'FULLNAME' },
|
||||||
|
{ value: 'status', label: 'وضعیت', defaultName: 'STATUS' },
|
||||||
|
{ value: 'amount', label: 'مبلغ', defaultName: 'AMOUNT' },
|
||||||
|
{ value: 'invoiceCode', label: 'کد صورتحساب', defaultName: 'INVOICECODE' },
|
||||||
|
{ value: 'course', label: 'نام دوره', defaultName: 'COURSE' },
|
||||||
|
{ value: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' }
|
||||||
|
],
|
||||||
|
accountCreated: [
|
||||||
|
{ value: 'username', label: 'نام کاربری', defaultName: 'USER' },
|
||||||
|
{ value: 'password', label: 'رمز عبور', defaultName: 'PASSWORD' },
|
||||||
|
{ value: 'fullName', label: 'نام و نام خانوادگی', defaultName: 'name' },
|
||||||
|
{ value: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' },
|
||||||
|
{ value: 'userCode', label: 'کد کاربری', defaultName: 'userCode' }
|
||||||
|
],
|
||||||
|
invoiceCreated: [
|
||||||
|
{ value: 'amount', label: 'مبلغ صورتحساب (تومان)', defaultName: 'PAYMENT_PRICE' },
|
||||||
|
{ value: 'fullName', label: 'نام کارآموز', defaultName: 'FULLNAME' },
|
||||||
|
{ value: 'course', label: 'نام دوره / کلاس', defaultName: 'COURSE' },
|
||||||
|
{ value: 'phoneNumber', label: 'شماره همراه', defaultName: 'MOBILE' },
|
||||||
|
{ value: 'classStartDate', label: 'تاریخ شروع کلاس', defaultName: 'classStartDate' },
|
||||||
|
{ value: 'classDays', label: 'روزهای برگزاری', defaultName: 'classDays' },
|
||||||
|
{ value: 'courseTime', label: 'ساعت دوره', defaultName: 'courseTime' },
|
||||||
|
{ value: 'invoiceCode', label: 'کد صورتحساب', defaultName: 'invoiceCode' }
|
||||||
|
],
|
||||||
|
classRegistered: [
|
||||||
|
{ value: 'fullName', label: 'نام کارآموز', defaultName: 'FULLNAME' },
|
||||||
|
{ value: 'className', label: 'نام کلاس', defaultName: 'CLASS' },
|
||||||
|
{ value: 'classDays', label: 'روزهای برگزاری', defaultName: 'CLASSDAYS' },
|
||||||
|
{ value: 'courseTime', label: 'ساعت دوره', defaultName: 'CLASSTIME' },
|
||||||
|
{ value: 'courseName', label: 'نام دوره', defaultName: 'courseName' },
|
||||||
|
{ value: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' },
|
||||||
|
{ value: 'classStartDate', label: 'تاریخ شروع کلاس', defaultName: 'classStartDate' },
|
||||||
|
{ value: 'classCode', label: 'کد کلاس', defaultName: 'classCode' }
|
||||||
|
],
|
||||||
|
classPlanProfessor: [
|
||||||
|
{ value: 'professorName', label: 'نام استاد', defaultName: 'professorName' },
|
||||||
|
{ value: 'className', label: 'نام کلاس', defaultName: 'className' },
|
||||||
|
{ value: 'classDays', label: 'روزهای برگزاری', defaultName: 'classDays' },
|
||||||
|
{ value: 'classTimes', label: 'ساعت برگزاری', defaultName: 'classTimes' },
|
||||||
|
{ value: 'classStartDate', label: 'تاریخ شروع', defaultName: 'classStartDate' },
|
||||||
|
{ value: 'classEndDate', label: 'تاریخ پایان', defaultName: 'classEndDate' },
|
||||||
|
{ value: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' }
|
||||||
|
],
|
||||||
|
passwordReset: [
|
||||||
|
{ value: 'username', label: 'نام کاربری', defaultName: 'USER' },
|
||||||
|
{ value: 'password', label: 'رمز عبور', defaultName: 'PASSWORD' },
|
||||||
|
{ value: 'fullName', label: 'نام کاربر', defaultName: 'name' },
|
||||||
|
{ value: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' },
|
||||||
|
{ value: 'userCode', label: 'کد کاربری', defaultName: 'userCode' }
|
||||||
|
],
|
||||||
|
classReminder: [
|
||||||
|
{ value: 'className', label: 'نام کلاس', defaultName: 'CLASSNAME' },
|
||||||
|
{ value: 'topic', label: 'موضوع جلسه', defaultName: 'topic' },
|
||||||
|
{ value: 'classTime', label: 'ساعت کلاس', defaultName: 'CLASSTIME' },
|
||||||
|
{ value: 'sessionDate', label: 'تاریخ جلسه', defaultName: 'SESSIONDATE' },
|
||||||
|
{ value: 'place', label: 'مکان برگزاری', defaultName: 'PLACE' },
|
||||||
|
{ value: 'fullName', label: 'نام کاربر', defaultName: 'FULLNAME' },
|
||||||
|
{ value: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' },
|
||||||
|
{ value: 'classStartDate', label: 'تاریخ شروع کلاس', defaultName: 'classStartDate' },
|
||||||
|
{ value: 'classDays', label: 'روزهای برگزاری', defaultName: 'classDays' },
|
||||||
|
{ value: 'courseTime', label: 'ساعت دوره', defaultName: 'courseTime' },
|
||||||
|
{ value: 'classCode', label: 'کد کلاس', defaultName: 'classCode' }
|
||||||
|
],
|
||||||
|
classRequestApproved: [
|
||||||
|
{ value: 'fullName', label: 'نام کارآموز', defaultName: 'FULLNAME' },
|
||||||
|
{ value: 'courseName', label: 'نام دوره', defaultName: 'COURSENAME' },
|
||||||
|
{ value: 'classCode', label: 'کد کلاس', defaultName: 'classCode' },
|
||||||
|
{ value: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' }
|
||||||
|
],
|
||||||
|
classRequestRejected: [
|
||||||
|
{ value: 'fullName', label: 'نام کارآموز', defaultName: 'FULLNAME' },
|
||||||
|
{ value: 'courseName', label: 'نام دوره', defaultName: 'COURSENAME' },
|
||||||
|
{ value: 'reason', label: 'دلیل', defaultName: 'REASON' },
|
||||||
|
{ value: 'registrationCode', label: 'کد درخواست', defaultName: 'registrationCode' },
|
||||||
|
{ value: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' }
|
||||||
|
],
|
||||||
|
pendingRegistration: [
|
||||||
|
{ value: 'fullName', label: 'نام کارآموز', defaultName: 'FULLNAME' },
|
||||||
|
{ value: 'className', label: 'نام کلاس', defaultName: 'CLASSNAME' },
|
||||||
|
{ value: 'registrationCode', label: 'کد درخواست', defaultName: 'REGISTRATIONCODE' },
|
||||||
|
{ value: 'phoneNumber', label: 'شماره همراه', defaultName: 'mobile' }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAvailableSlots = (templateKey) => {
|
||||||
|
return TEMPLATE_SLOT_DEFS[templateKey] || [];
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSlotDefaultName = (templateKey, slotKey) => {
|
||||||
|
const slots = getAvailableSlots(templateKey);
|
||||||
|
const found = slots.find((s) => s.value === slotKey);
|
||||||
|
return found?.defaultName || '';
|
||||||
|
};
|
||||||
|
|
||||||
|
let varIdCounter = 0;
|
||||||
|
const nextVarId = () => `var_${++varIdCounter}_${Date.now()}`;
|
||||||
|
|
||||||
|
const addVariable = (templateKey) => {
|
||||||
|
const form = templateForm.value[templateKey];
|
||||||
|
if (!form) return;
|
||||||
|
if (!Array.isArray(form.variables)) {
|
||||||
|
form.variables = [];
|
||||||
|
}
|
||||||
|
const availableSlots = getAvailableSlots(templateKey);
|
||||||
|
const existingSlots = new Set(form.variables.map((v) => v.slot));
|
||||||
|
const unusedSlot = availableSlots.find((s) => !existingSlots.has(s.value));
|
||||||
|
const selectedSlot = unusedSlot || availableSlots[0] || { value: 'amount', defaultName: 'PAYMENT_PRICE' };
|
||||||
|
|
||||||
|
form.variables.push({
|
||||||
|
id: nextVarId(),
|
||||||
|
slot: selectedSlot.value,
|
||||||
|
name: selectedSlot.defaultName || ''
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeVariable = (templateKey, index) => {
|
||||||
|
const form = templateForm.value[templateKey];
|
||||||
|
if (form && form.variables) {
|
||||||
|
form.variables.splice(index, 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const emptyFormEntry = (template) => {
|
||||||
|
const slots = getAvailableSlots(template?.key);
|
||||||
|
let vars = [];
|
||||||
|
const incomingVars = Array.isArray(template?.variables)
|
||||||
|
? template.variables
|
||||||
|
: Object.values(template?.variables || {});
|
||||||
|
if (incomingVars.length > 0) {
|
||||||
|
vars = incomingVars.map((variable) => ({
|
||||||
|
id: nextVarId(),
|
||||||
|
slot: variable.slot || slots[0]?.value || 'amount',
|
||||||
|
name: variable.name || getSlotDefaultName(template?.key, variable.slot) || ''
|
||||||
|
}));
|
||||||
|
} else if (!template?.variables) {
|
||||||
|
vars = slots.map((s) => ({
|
||||||
|
id: nextVarId(),
|
||||||
|
slot: s.value,
|
||||||
|
name: s.defaultName
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled: template?.enabled !== false,
|
||||||
|
templateId: template?.templateId || '',
|
||||||
|
category: template?.category || 'اطلاعرسانی',
|
||||||
|
text: template?.text || '',
|
||||||
|
variables: vars
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadTemplates = async () => {
|
||||||
|
isLoading.value = true;
|
||||||
|
try {
|
||||||
|
const response = await settingsApi.get();
|
||||||
|
const data = response?.data?.data || response?.data || response || {};
|
||||||
|
const templates = Array.isArray(data.smsTemplates) ? data.smsTemplates : [];
|
||||||
|
smsTemplates.value = templates;
|
||||||
|
|
||||||
|
const newForm = {};
|
||||||
|
templates.forEach((template) => {
|
||||||
|
newForm[template.key] = emptyFormEntry(template);
|
||||||
|
});
|
||||||
|
templateForm.value = newForm;
|
||||||
|
} catch (err) {
|
||||||
|
showError(err);
|
||||||
|
} finally {
|
||||||
|
isLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveSmsTemplates = async () => {
|
||||||
|
isSaving.value = true;
|
||||||
|
try {
|
||||||
|
const smsTemplatesPayload = {};
|
||||||
|
smsTemplates.value.forEach((template) => {
|
||||||
|
const entry = templateForm.value[template.key] || emptyFormEntry(template);
|
||||||
|
const rawVariables = Array.isArray(entry.variables)
|
||||||
|
? entry.variables
|
||||||
|
: Object.values(entry.variables || {});
|
||||||
|
smsTemplatesPayload[template.key] = {
|
||||||
|
enabled: Boolean(entry.enabled),
|
||||||
|
templateId: String(entry.templateId || '').trim(),
|
||||||
|
category: entry.category || 'اطلاعرسانی',
|
||||||
|
text: String(entry.text || '').trim(),
|
||||||
|
variables: rawVariables
|
||||||
|
.filter((v) => v && v.slot)
|
||||||
|
.map((v) => {
|
||||||
|
const raw = String(v.name || '').trim().replace(/^#+|#+$/g, '');
|
||||||
|
const fallback = getSlotDefaultName(template.key, v.slot) || v.slot;
|
||||||
|
return {
|
||||||
|
slot: v.slot,
|
||||||
|
name: raw || fallback
|
||||||
|
};
|
||||||
|
})
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const response = await settingsApi.save({ smsTemplates: smsTemplatesPayload });
|
||||||
|
const data = response?.data?.data || response?.data || response || {};
|
||||||
|
if (Array.isArray(data.smsTemplates)) {
|
||||||
|
smsTemplates.value = data.smsTemplates;
|
||||||
|
const newForm = {};
|
||||||
|
data.smsTemplates.forEach((template) => {
|
||||||
|
newForm[template.key] = emptyFormEntry(template);
|
||||||
|
});
|
||||||
|
templateForm.value = newForm;
|
||||||
|
}
|
||||||
|
showSuccess('قالبهای پیامک با موفقیت ذخیره شدند.');
|
||||||
|
} catch (err) {
|
||||||
|
showError(err);
|
||||||
|
} finally {
|
||||||
|
isSaving.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadTemplates();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.notification-templates-view {
|
||||||
|
min-height: 80vh;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -152,7 +152,10 @@
|
|||||||
<div class="flex flex-column gap-3 py-2">
|
<div class="flex flex-column gap-3 py-2">
|
||||||
<div class="flex flex-column gap-2">
|
<div class="flex flex-column gap-2">
|
||||||
<label class="font-semibold text-sm">مبلغ واریزی (تومان) *</label>
|
<label class="font-semibold text-sm">مبلغ واریزی (تومان) *</label>
|
||||||
<InputNumber v-model="trxForm.amount" class="w-full text-sm" suffix=" تومان" :min="1" />
|
<InputGroup>
|
||||||
|
<InputNumber v-model="trxForm.amount" class="w-full text-sm" :min="1" />
|
||||||
|
<InputGroupAddon>تومان</InputGroupAddon>
|
||||||
|
</InputGroup>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-column gap-2">
|
<div class="flex flex-column gap-2">
|
||||||
<label class="font-semibold text-sm">وضعیت تراکنش *</label>
|
<label class="font-semibold text-sm">وضعیت تراکنش *</label>
|
||||||
@@ -198,7 +201,10 @@
|
|||||||
<div class="flex flex-column gap-3 py-2">
|
<div class="flex flex-column gap-3 py-2">
|
||||||
<div class="flex flex-column gap-2">
|
<div class="flex flex-column gap-2">
|
||||||
<label class="font-semibold text-sm">مبلغ (تومان) *</label>
|
<label class="font-semibold text-sm">مبلغ (تومان) *</label>
|
||||||
<InputNumber v-model="editForm.amount" class="w-full text-sm" suffix=" تومان" :min="0" />
|
<InputGroup>
|
||||||
|
<InputNumber v-model="editForm.amount" class="w-full text-sm" :min="0" />
|
||||||
|
<InputGroupAddon>تومان</InputGroupAddon>
|
||||||
|
</InputGroup>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-column gap-2">
|
<div class="flex flex-column gap-2">
|
||||||
<label class="font-semibold text-sm">وضعیت تراکنش *</label>
|
<label class="font-semibold text-sm">وضعیت تراکنش *</label>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
<div class="payment-list-view">
|
<div class="payment-list-view">
|
||||||
<PageHeader :title="$t('payments.title')" :subtitle="$t('payments.subtitle')">
|
<PageHeader :title="$t('payments.title')" :subtitle="$t('payments.subtitle')">
|
||||||
<PermissionGate permission="payments:create">
|
<PermissionGate permission="payments:create">
|
||||||
|
<Button label="صدور صورتحساب گروهی" icon="pi pi-users" severity="info" class="ml-2" @click="openBulkModal" />
|
||||||
<Button :label="$t('payments.addPayment')" icon="pi pi-plus" severity="success" @click="openCreateModal" />
|
<Button :label="$t('payments.addPayment')" icon="pi pi-plus" severity="success" @click="openCreateModal" />
|
||||||
</PermissionGate>
|
</PermissionGate>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
@@ -64,9 +65,21 @@
|
|||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
|
|
||||||
<Column header="عملیات" style="width: 110px">
|
<Column header="عملیات" style="width: 140px">
|
||||||
<template #body="{ data }">
|
<template #body="{ data }">
|
||||||
<div class="flex align-items-center gap-1">
|
<div class="flex align-items-center gap-1">
|
||||||
|
<PermissionGate permission="payments:update">
|
||||||
|
<Button
|
||||||
|
icon="pi pi-pencil"
|
||||||
|
text
|
||||||
|
rounded
|
||||||
|
size="small"
|
||||||
|
severity="secondary"
|
||||||
|
v-tooltip.top="'ویرایش سریع'"
|
||||||
|
@click="openQuickEdit(data)"
|
||||||
|
/>
|
||||||
|
</PermissionGate>
|
||||||
|
|
||||||
<PermissionGate permission="payments:read">
|
<PermissionGate permission="payments:read">
|
||||||
<Button icon="pi pi-eye" text rounded size="small" v-tooltip.top="'مشاهده'" @click="$router.push(`/payments/view/${data._id || data.id}`)" />
|
<Button icon="pi pi-eye" text rounded size="small" v-tooltip.top="'مشاهده'" @click="$router.push(`/payments/view/${data._id || data.id}`)" />
|
||||||
</PermissionGate>
|
</PermissionGate>
|
||||||
@@ -108,15 +121,46 @@
|
|||||||
:placeholder="createForm.classes?.length ? 'دانشجوی ثبتنامشده را انتخاب کنید' : 'ابتدا کلاس را انتخاب کنید'"
|
:placeholder="createForm.classes?.length ? 'دانشجوی ثبتنامشده را انتخاب کنید' : 'ابتدا کلاس را انتخاب کنید'"
|
||||||
class="w-full text-sm"
|
class="w-full text-sm"
|
||||||
:disabled="!createForm.classes?.length"
|
:disabled="!createForm.classes?.length"
|
||||||
|
@change="() => checkDuplicateInvoice()"
|
||||||
/>
|
/>
|
||||||
<small v-if="createForm.classes?.length && !eligibleUsers.length" class="text-orange-500">
|
<small v-if="createForm.classes?.length && !eligibleUsers.length" class="text-orange-500">
|
||||||
هیچ دانشجوی ثبتنامشدهای در کلاسهای انتخابشده یافت نشد
|
هیچ دانشجوی ثبتنامشدهای در کلاسهای انتخابشده یافت نشد
|
||||||
</small>
|
</small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Duplicate Payment Warning -->
|
||||||
|
<div
|
||||||
|
v-if="duplicateWarning && duplicateWarning.hasDuplicate"
|
||||||
|
class="p-3 border-round flex flex-column gap-2 duplicate-warning-box"
|
||||||
|
>
|
||||||
|
<div class="flex align-items-center gap-2">
|
||||||
|
<i class="pi pi-exclamation-triangle text-xl text-yellow-500 flex-shrink-0"></i>
|
||||||
|
<span class="font-bold text-sm text-yellow-800 dark:text-yellow-200">
|
||||||
|
توجه: برای این دانشجو قبلاً در این کلاس صورتحساب ثبت شده است.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-column gap-2 mt-1 text-xs" v-if="duplicateWarning.payments && duplicateWarning.payments.length">
|
||||||
|
<div
|
||||||
|
v-for="p in duplicateWarning.payments"
|
||||||
|
:key="p._id"
|
||||||
|
class="duplicate-item p-2 border-round flex flex-wrap align-items-center justify-content-between gap-2"
|
||||||
|
>
|
||||||
|
<div class="flex align-items-center gap-2 flex-wrap">
|
||||||
|
<span>کد صورتحساب: <strong>{{ p.uniqueCode || p._id }}</strong></span>
|
||||||
|
<span v-if="p.amount">مبلغ: <strong>{{ toPersianDigits((p.amount || 0).toLocaleString()) }} تومان</strong></span>
|
||||||
|
<span v-if="p.createdAt" class="text-color-secondary">تاریخ: {{ formatJalali(p.createdAt) }}</span>
|
||||||
|
</div>
|
||||||
|
<StatusTag v-if="p.status" :value="p.status" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-column gap-2">
|
<div class="flex flex-column gap-2">
|
||||||
<label class="font-semibold text-sm" for="invoice-amount">مبلغ کل صورتحساب (تومان) *</label>
|
<label class="font-semibold text-sm" for="invoice-amount">مبلغ کل صورتحساب (تومان) *</label>
|
||||||
<InputNumber inputId="invoice-amount" v-model="createForm.amount" class="w-full text-sm" suffix=" تومان" :min="0" />
|
<InputGroup>
|
||||||
|
<InputNumber inputId="invoice-amount" v-model="createForm.amount" class="w-full text-sm" :min="0" />
|
||||||
|
<InputGroupAddon>تومان</InputGroupAddon>
|
||||||
|
</InputGroup>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex align-items-center gap-2">
|
<div class="flex align-items-center gap-2">
|
||||||
@@ -126,14 +170,16 @@
|
|||||||
|
|
||||||
<div v-if="hasDiscount" class="flex flex-column gap-2">
|
<div v-if="hasDiscount" class="flex flex-column gap-2">
|
||||||
<label class="font-semibold text-sm" for="invoice-discount-amount">مبلغ تخفیف (تومان)</label>
|
<label class="font-semibold text-sm" for="invoice-discount-amount">مبلغ تخفیف (تومان)</label>
|
||||||
<InputNumber
|
<InputGroup>
|
||||||
inputId="invoice-discount-amount"
|
<InputNumber
|
||||||
v-model="createForm.discount"
|
inputId="invoice-discount-amount"
|
||||||
class="w-full text-sm"
|
v-model="createForm.discount"
|
||||||
suffix=" تومان"
|
class="w-full text-sm"
|
||||||
:min="0"
|
:min="0"
|
||||||
:max="createForm.amount || 0"
|
:max="createForm.amount || 0"
|
||||||
/>
|
/>
|
||||||
|
<InputGroupAddon>تومان</InputGroupAddon>
|
||||||
|
</InputGroup>
|
||||||
<small class="font-semibold text-color">
|
<small class="font-semibold text-color">
|
||||||
مبلغ نهایی: {{ toPersianDigits(createPayableAmount.toLocaleString()) }} تومان
|
مبلغ نهایی: {{ toPersianDigits(createPayableAmount.toLocaleString()) }} تومان
|
||||||
</small>
|
</small>
|
||||||
@@ -164,28 +210,127 @@
|
|||||||
</template>
|
</template>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
<!-- Bulk Class Payment Modal -->
|
||||||
|
<Dialog v-model:visible="showBulkModal" header="صدور صورتحساب گروهی برای کلاس" modal :style="{ width: '560px' }">
|
||||||
|
<div class="flex flex-column gap-3 py-2">
|
||||||
|
<div class="flex flex-column gap-2">
|
||||||
|
<label class="font-semibold text-sm">انتخاب کلاس *</label>
|
||||||
|
<Dropdown
|
||||||
|
v-model="bulkForm.classId"
|
||||||
|
:options="classesList"
|
||||||
|
optionLabel="name"
|
||||||
|
optionValue="_id"
|
||||||
|
filter
|
||||||
|
placeholder="کلاس را انتخاب کنید"
|
||||||
|
class="w-full text-sm"
|
||||||
|
@change="onBulkClassSelected"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="selectedBulkClass" class="p-3 bg-blue-50 border-round border-1 border-blue-200 text-blue-900 text-sm flex align-items-center justify-content-between">
|
||||||
|
<span>تعداد کل دانشجویان کلاس:</span>
|
||||||
|
<span class="font-bold">{{ toPersianDigits((selectedBulkClass.students || []).length) }} نفر</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-column gap-2">
|
||||||
|
<label class="font-semibold text-sm" for="bulk-invoice-amount">مبلغ شهریه هر دانشجو (تومان) *</label>
|
||||||
|
<InputGroup>
|
||||||
|
<InputNumber inputId="bulk-invoice-amount" v-model="bulkForm.amount" class="w-full text-sm" :min="0" />
|
||||||
|
<InputGroupAddon>تومان</InputGroupAddon>
|
||||||
|
</InputGroup>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex align-items-center gap-2">
|
||||||
|
<Checkbox v-model="hasBulkDiscount" binary inputId="bulk-invoice-discount" />
|
||||||
|
<label for="bulk-invoice-discount" class="font-semibold text-sm cursor-pointer">تخفیف همگانی ؟</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="hasBulkDiscount" class="flex flex-column gap-2">
|
||||||
|
<label class="font-semibold text-sm" for="bulk-invoice-discount-amount">مبلغ تخفیف (تومان)</label>
|
||||||
|
<InputGroup>
|
||||||
|
<InputNumber
|
||||||
|
inputId="bulk-invoice-discount-amount"
|
||||||
|
v-model="bulkForm.discount"
|
||||||
|
class="w-full text-sm"
|
||||||
|
:min="0"
|
||||||
|
:max="bulkForm.amount || 0"
|
||||||
|
/>
|
||||||
|
<InputGroupAddon>تومان</InputGroupAddon>
|
||||||
|
</InputGroup>
|
||||||
|
<small class="font-semibold text-color">
|
||||||
|
مبلغ نهایی هر صورتحساب: {{ toPersianDigits(bulkPayableAmount.toLocaleString()) }} تومان
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-column gap-2">
|
||||||
|
<label class="font-semibold text-sm">تاریخ سررسید *</label>
|
||||||
|
<DatePicker v-model="bulkForm.dueDate" class="w-full text-sm" :placeholder="getTodayJalali()" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex align-items-center gap-2">
|
||||||
|
<Checkbox v-model="bulkForm.skipExisting" binary inputId="bulk-skip-existing" />
|
||||||
|
<label for="bulk-skip-existing" class="text-sm cursor-pointer font-medium">
|
||||||
|
عدم صدور مجدد برای دانشجویانی که قبلاً در این کلاس صورتحساب دارند
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-column gap-2">
|
||||||
|
<label class="font-semibold text-sm" for="bulk-invoice-notes">یادداشت</label>
|
||||||
|
<Textarea
|
||||||
|
id="bulk-invoice-notes"
|
||||||
|
v-model="bulkForm.notes"
|
||||||
|
rows="2"
|
||||||
|
class="w-full text-sm"
|
||||||
|
maxlength="5000"
|
||||||
|
placeholder="یادداشت برای تمام صورتحسابهای صادره…"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<NotifyChannelsField :notify="bulkNotify" />
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<Button label="انصراف" text severity="secondary" @click="showBulkModal = false" />
|
||||||
|
<Button
|
||||||
|
label="صدور صورتحسابها"
|
||||||
|
icon="pi pi-check"
|
||||||
|
severity="success"
|
||||||
|
:loading="isBulkCreating"
|
||||||
|
:disabled="!bulkForm.classId || !(selectedBulkClass?.students?.length)"
|
||||||
|
@click="handleBulkCreatePayment"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
<ConfirmDeleteDialog
|
<ConfirmDeleteDialog
|
||||||
v-model="deleteDialogVisible"
|
v-model="deleteDialogVisible"
|
||||||
:loading="isDeleting"
|
:loading="isDeleting"
|
||||||
@confirm="handleDelete"
|
@confirm="handleDelete"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<QuickEditPaymentDialog
|
||||||
|
v-model:visible="quickEditVisible"
|
||||||
|
:payment-id="quickEditPaymentId"
|
||||||
|
@updated="loadData"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, reactive, computed, onMounted } from 'vue';
|
import { ref, reactive, computed, watch, onMounted } from 'vue';
|
||||||
import { useDataTable } from '@/composables/useDataTable';
|
import { useDataTable } from '@/composables/useDataTable';
|
||||||
import { usePersianDate } from '@/composables/usePersianDate';
|
import { usePersianDate } from '@/composables/usePersianDate';
|
||||||
import { useToast } from '@/composables/useToast';
|
import { useToast } from '@/composables/useToast';
|
||||||
import { paymentApi } from '@/api/paymentApi';
|
import { paymentApi } from '@/api/paymentApi';
|
||||||
import { userApi } from '@/api/userApi';
|
import { userApi } from '@/api/userApi';
|
||||||
import { classApi } from '@/api/classApi';
|
import { classApi } from '@/api/classApi';
|
||||||
|
import { sessionApi } from '@/api/sessionApi';
|
||||||
import PageHeader from '@/components/common/PageHeader.vue';
|
import PageHeader from '@/components/common/PageHeader.vue';
|
||||||
import DataTableWrapper from '@/components/common/DataTableWrapper.vue';
|
import DataTableWrapper from '@/components/common/DataTableWrapper.vue';
|
||||||
import ConfirmDeleteDialog from '@/components/common/ConfirmDeleteDialog.vue';
|
import ConfirmDeleteDialog from '@/components/common/ConfirmDeleteDialog.vue';
|
||||||
import PermissionGate from '@/components/common/PermissionGate.vue';
|
import PermissionGate from '@/components/common/PermissionGate.vue';
|
||||||
import StatusTag from '@/components/common/StatusTag.vue';
|
import StatusTag from '@/components/common/StatusTag.vue';
|
||||||
import NotifyChannelsField from '@/components/common/NotifyChannelsField.vue';
|
import NotifyChannelsField from '@/components/common/NotifyChannelsField.vue';
|
||||||
|
import QuickEditPaymentDialog from '@/components/payments/QuickEditPaymentDialog.vue';
|
||||||
import { getPayableAmount } from '@/utils/paymentAmount';
|
import { getPayableAmount } from '@/utils/paymentAmount';
|
||||||
import { calculateClassMidDate } from '@/utils/classSchedule';
|
import { calculateClassMidDate } from '@/utils/classSchedule';
|
||||||
import Button from 'primevue/button';
|
import Button from 'primevue/button';
|
||||||
@@ -195,13 +340,15 @@ import Dialog from 'primevue/dialog';
|
|||||||
import Dropdown from 'primevue/select';
|
import Dropdown from 'primevue/select';
|
||||||
import MultiSelect from 'primevue/multiselect';
|
import MultiSelect from 'primevue/multiselect';
|
||||||
import InputNumber from 'primevue/inputnumber';
|
import InputNumber from 'primevue/inputnumber';
|
||||||
|
import InputGroup from 'primevue/inputgroup';
|
||||||
|
import InputGroupAddon from 'primevue/inputgroupaddon';
|
||||||
import Checkbox from 'primevue/checkbox';
|
import Checkbox from 'primevue/checkbox';
|
||||||
import Textarea from 'primevue/textarea';
|
import Textarea from 'primevue/textarea';
|
||||||
import DatePicker from 'vue3-persian-datetime-picker';
|
import DatePicker from 'vue3-persian-datetime-picker';
|
||||||
|
|
||||||
const { toPersianDigits, formatJalali, toGregorianIso, getTodayJalali } = usePersianDate();
|
const { toPersianDigits, formatJalali, toGregorianIso, getTodayJalali } = usePersianDate();
|
||||||
|
|
||||||
const { showSuccess, showError } = useToast();
|
const { showSuccess, showError, showWarn } = useToast();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
items,
|
items,
|
||||||
@@ -218,6 +365,16 @@ const showCreateModal = ref(false);
|
|||||||
const usersList = ref([]);
|
const usersList = ref([]);
|
||||||
const classesList = ref([]);
|
const classesList = ref([]);
|
||||||
const isCreating = ref(false);
|
const isCreating = ref(false);
|
||||||
|
const duplicateWarning = ref(null);
|
||||||
|
let lastDuplicateAlertKey = '';
|
||||||
|
|
||||||
|
const quickEditVisible = ref(false);
|
||||||
|
const quickEditPaymentId = ref('');
|
||||||
|
|
||||||
|
const openQuickEdit = (row) => {
|
||||||
|
quickEditPaymentId.value = String(row._id || row.id);
|
||||||
|
quickEditVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
const createForm = reactive({
|
const createForm = reactive({
|
||||||
user: null,
|
user: null,
|
||||||
@@ -234,6 +391,30 @@ const createPayableAmount = computed(() => getPayableAmount({
|
|||||||
discount: hasDiscount.value ? createForm.discount : 0
|
discount: hasDiscount.value ? createForm.discount : 0
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Bulk payment state
|
||||||
|
const showBulkModal = ref(false);
|
||||||
|
const isBulkCreating = ref(false);
|
||||||
|
const hasBulkDiscount = ref(false);
|
||||||
|
const bulkNotify = reactive({ sms: true, email: true, bot: true });
|
||||||
|
const bulkForm = reactive({
|
||||||
|
classId: null,
|
||||||
|
amount: 0,
|
||||||
|
discount: 0,
|
||||||
|
notes: '',
|
||||||
|
dueDate: getTodayJalali(),
|
||||||
|
skipExisting: true
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedBulkClass = computed(() => {
|
||||||
|
if (!bulkForm.classId) return null;
|
||||||
|
return classesList.value.find((c) => String(c._id || c.id) === String(bulkForm.classId));
|
||||||
|
});
|
||||||
|
|
||||||
|
const bulkPayableAmount = computed(() => getPayableAmount({
|
||||||
|
amount: bulkForm.amount,
|
||||||
|
discount: hasBulkDiscount.value ? bulkForm.discount : 0
|
||||||
|
}));
|
||||||
|
|
||||||
const deleteDialogVisible = ref(false);
|
const deleteDialogVisible = ref(false);
|
||||||
const selectedPayment = ref(null);
|
const selectedPayment = ref(null);
|
||||||
const isDeleting = ref(false);
|
const isDeleting = ref(false);
|
||||||
@@ -263,11 +444,81 @@ const eligibleUsers = computed(() => {
|
|||||||
return usersList.value.filter((u) => eligibleIds?.has(String(u._id || u.id)));
|
return usersList.value.filter((u) => eligibleIds?.has(String(u._id || u.id)));
|
||||||
});
|
});
|
||||||
|
|
||||||
const onClassesSelected = () => {
|
const extractId = (val) => {
|
||||||
|
if (!val) return null;
|
||||||
|
if (typeof val === 'object') {
|
||||||
|
if (val.value !== undefined && (typeof val.value === 'string' || typeof val.value === 'number')) {
|
||||||
|
return String(val.value);
|
||||||
|
}
|
||||||
|
return val._id ? String(val._id) : (val.id ? String(val.id) : null);
|
||||||
|
}
|
||||||
|
return String(val);
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkDuplicateInvoice = async (userIdVal = null) => {
|
||||||
|
const targetUserId = extractId(userIdVal) || extractId(createForm.user);
|
||||||
|
if (!targetUserId || !createForm.classes || !createForm.classes.length) {
|
||||||
|
duplicateWarning.value = null;
|
||||||
|
lastDuplicateAlertKey = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const classIds = Array.isArray(createForm.classes)
|
||||||
|
? createForm.classes.map(extractId).filter(Boolean).sort().join(',')
|
||||||
|
: (extractId(createForm.classes) || '');
|
||||||
|
if (!classIds) {
|
||||||
|
duplicateWarning.value = null;
|
||||||
|
lastDuplicateAlertKey = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const checkKey = `${targetUserId}_${classIds}`;
|
||||||
|
const res = await paymentApi.checkDuplicate({
|
||||||
|
userId: targetUserId,
|
||||||
|
classes: classIds
|
||||||
|
});
|
||||||
|
const result = res.data?.data || res.data || res;
|
||||||
|
duplicateWarning.value = result;
|
||||||
|
|
||||||
|
if (result && result.hasDuplicate) {
|
||||||
|
if (lastDuplicateAlertKey !== checkKey) {
|
||||||
|
lastDuplicateAlertKey = checkKey;
|
||||||
|
const studentObj = usersList.value.find((u) => String(u._id || u.id) === String(targetUserId));
|
||||||
|
const studentName = studentObj?.name || studentObj?.fullName || 'این دانشجو';
|
||||||
|
const codes = (result.payments || []).map((p) => p.uniqueCode || p._id).filter(Boolean).join('، ');
|
||||||
|
const codeSuffix = codes ? ` (کد: ${codes})` : '';
|
||||||
|
showWarn(`برای ${studentName} قبلاً در این کلاس صورتحساب ثبت شده است${codeSuffix}.`, 'اطلاعیه صورتحساب قبلی');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
lastDuplicateAlertKey = '';
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('checkDuplicateInvoice error:', err);
|
||||||
|
duplicateWarning.value = null;
|
||||||
|
lastDuplicateAlertKey = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
watch(
|
||||||
|
[() => createForm.user, () => createForm.classes],
|
||||||
|
async ([newUser, newClasses]) => {
|
||||||
|
if (newUser && newClasses && newClasses.length) {
|
||||||
|
await checkDuplicateInvoice(newUser);
|
||||||
|
} else {
|
||||||
|
duplicateWarning.value = null;
|
||||||
|
lastDuplicateAlertKey = '';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ deep: true, immediate: true }
|
||||||
|
);
|
||||||
|
|
||||||
|
const onClassesSelected = async () => {
|
||||||
if (createForm.user && !eligibleUsers.value.some((u) => (u._id || u.id) === createForm.user)) {
|
if (createForm.user && !eligibleUsers.value.some((u) => (u._id || u.id) === createForm.user)) {
|
||||||
createForm.user = null;
|
createForm.user = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await checkDuplicateInvoice();
|
||||||
|
|
||||||
if (!createForm.classes || createForm.classes.length === 0) {
|
if (!createForm.classes || createForm.classes.length === 0) {
|
||||||
createForm.amount = 0;
|
createForm.amount = 0;
|
||||||
createForm.discount = 0;
|
createForm.discount = 0;
|
||||||
@@ -287,8 +538,24 @@ const onClassesSelected = () => {
|
|||||||
if ((createForm.discount || 0) > (createForm.amount || 0)) {
|
if ((createForm.discount || 0) > (createForm.amount || 0)) {
|
||||||
createForm.discount = createForm.amount || 0;
|
createForm.discount = createForm.amount || 0;
|
||||||
}
|
}
|
||||||
if (!createForm.dueDate) {
|
|
||||||
createForm.dueDate = getTodayJalali();
|
const selectedClassId = createForm.classes[createForm.classes.length - 1];
|
||||||
|
const selectedClass = classesList.value.find((item) => String(item._id || item.id) === String(selectedClassId));
|
||||||
|
if (selectedClass) {
|
||||||
|
try {
|
||||||
|
const res = await sessionApi.getAll({
|
||||||
|
class: selectedClassId,
|
||||||
|
sortBy: 'day',
|
||||||
|
sortOrder: 'asc',
|
||||||
|
limit: 100
|
||||||
|
});
|
||||||
|
const data = res.data || res;
|
||||||
|
const rawSessions = data.items || data.sessions || data.data || data || [];
|
||||||
|
const sessionList = Array.isArray(rawSessions) ? rawSessions : [];
|
||||||
|
createForm.dueDate = calculateClassMidDate(selectedClass, sessionList);
|
||||||
|
} catch (e) {
|
||||||
|
createForm.dueDate = calculateClassMidDate(selectedClass);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -300,17 +567,116 @@ const resetCreateForm = () => {
|
|||||||
createForm.notes = '';
|
createForm.notes = '';
|
||||||
createForm.dueDate = getTodayJalali();
|
createForm.dueDate = getTodayJalali();
|
||||||
hasDiscount.value = false;
|
hasDiscount.value = false;
|
||||||
|
duplicateWarning.value = null;
|
||||||
|
lastDuplicateAlertKey = '';
|
||||||
createNotify.sms = true;
|
createNotify.sms = true;
|
||||||
createNotify.email = true;
|
createNotify.email = true;
|
||||||
createNotify.bot = true;
|
createNotify.bot = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const openCreateModal = () => {
|
const openCreateModal = () => {
|
||||||
resetCreateForm();
|
resetCreateForm();
|
||||||
showCreateModal.value = true;
|
showCreateModal.value = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const resetBulkForm = () => {
|
||||||
|
bulkForm.classId = null;
|
||||||
|
bulkForm.amount = 0;
|
||||||
|
bulkForm.discount = 0;
|
||||||
|
bulkForm.notes = '';
|
||||||
|
bulkForm.dueDate = getTodayJalali();
|
||||||
|
bulkForm.skipExisting = true;
|
||||||
|
hasBulkDiscount.value = false;
|
||||||
|
bulkNotify.sms = true;
|
||||||
|
bulkNotify.email = true;
|
||||||
|
bulkNotify.bot = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const openBulkModal = () => {
|
||||||
|
resetBulkForm();
|
||||||
|
showBulkModal.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onBulkClassSelected = async () => {
|
||||||
|
if (!bulkForm.classId) {
|
||||||
|
bulkForm.amount = 0;
|
||||||
|
bulkForm.discount = 0;
|
||||||
|
bulkForm.dueDate = getTodayJalali();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const cls = selectedBulkClass.value;
|
||||||
|
if (cls) {
|
||||||
|
bulkForm.amount = cls.tuitionFee || cls.course?.price || 0;
|
||||||
|
if (cls.hasDiscount && cls.discount) {
|
||||||
|
hasBulkDiscount.value = true;
|
||||||
|
bulkForm.discount = cls.discount;
|
||||||
|
} else {
|
||||||
|
hasBulkDiscount.value = false;
|
||||||
|
bulkForm.discount = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await sessionApi.getAll({
|
||||||
|
class: bulkForm.classId,
|
||||||
|
sortBy: 'day',
|
||||||
|
sortOrder: 'asc',
|
||||||
|
limit: 100
|
||||||
|
});
|
||||||
|
const data = res.data || res;
|
||||||
|
const rawSessions = data.items || data.sessions || data.data || data || [];
|
||||||
|
const sessionList = Array.isArray(rawSessions) ? rawSessions : [];
|
||||||
|
bulkForm.dueDate = calculateClassMidDate(cls, sessionList);
|
||||||
|
} catch (e) {
|
||||||
|
bulkForm.dueDate = calculateClassMidDate(cls);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBulkCreatePayment = async () => {
|
||||||
|
if (!bulkForm.classId) { showError('لطفا کلاس را انتخاب کنید'); return; }
|
||||||
|
if (!selectedBulkClass.value?.students?.length) {
|
||||||
|
showError('هیچ دانشجویی در این کلاس ثبتنام نشده است');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!bulkForm.amount) { showError('لطفا مبلغ شهریه را وارد کنید'); return; }
|
||||||
|
if (!bulkForm.dueDate) { showError('لطفا تاریخ سررسید را وارد کنید'); return; }
|
||||||
|
const dueDate = toGregorianIso(bulkForm.dueDate);
|
||||||
|
if (!dueDate) { showError('تاریخ سررسید نامعتبر است'); return; }
|
||||||
|
|
||||||
|
if (hasBulkDiscount.value && (bulkForm.discount || 0) > bulkForm.amount) {
|
||||||
|
showError('مبلغ تخفیف نمیتواند بیشتر از مبلغ کل باشد');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isBulkCreating.value = true;
|
||||||
|
try {
|
||||||
|
const res = await paymentApi.createBulkClass({
|
||||||
|
classId: bulkForm.classId,
|
||||||
|
amount: bulkForm.amount,
|
||||||
|
discount: hasBulkDiscount.value ? (bulkForm.discount || 0) : 0,
|
||||||
|
dueDate,
|
||||||
|
notes: bulkForm.notes,
|
||||||
|
skipExisting: bulkForm.skipExisting,
|
||||||
|
notify: { ...bulkNotify }
|
||||||
|
});
|
||||||
|
const result = res.data?.data || res.data || res;
|
||||||
|
const createdCount = result.createdCount ?? 0;
|
||||||
|
const skippedCount = result.skippedCount ?? 0;
|
||||||
|
|
||||||
|
let msg = `صورتحساب برای ${toPersianDigits(createdCount)} دانشجو با موفقیت ایجاد شد`;
|
||||||
|
if (skippedCount > 0) {
|
||||||
|
msg += ` (${toPersianDigits(skippedCount)} دانشجو به دلیل داشتن صورتحساب قبلی رد شدند)`;
|
||||||
|
}
|
||||||
|
showSuccess(msg);
|
||||||
|
showBulkModal.value = false;
|
||||||
|
loadData();
|
||||||
|
} catch (err) {
|
||||||
|
showError(err);
|
||||||
|
} finally {
|
||||||
|
isBulkCreating.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const fetchDropdownData = async () => {
|
const fetchDropdownData = async () => {
|
||||||
try {
|
try {
|
||||||
const [uRes, cRes] = await Promise.all([
|
const [uRes, cRes] = await Promise.all([
|
||||||
@@ -384,3 +750,15 @@ onMounted(() => {
|
|||||||
fetchDropdownData();
|
fetchDropdownData();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.duplicate-warning-box {
|
||||||
|
background: rgba(245, 158, 11, 0.12) !important;
|
||||||
|
border: 1px solid rgba(245, 158, 11, 0.45) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duplicate-item {
|
||||||
|
background: rgba(245, 158, 11, 0.08);
|
||||||
|
border: 1px dashed rgba(245, 158, 11, 0.35);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -3,7 +3,10 @@
|
|||||||
<div class="professor-list-view">
|
<div class="professor-list-view">
|
||||||
<PageHeader :title="$t('professors.title')" :subtitle="$t('professors.subtitle')">
|
<PageHeader :title="$t('professors.title')" :subtitle="$t('professors.subtitle')">
|
||||||
<PermissionGate permission="professors:create">
|
<PermissionGate permission="professors:create">
|
||||||
<Button :label="$t('professors.addProfessor')" icon="pi pi-plus" @click="$router.push('/professors/create')" />
|
<div class="flex gap-2">
|
||||||
|
<Button label="افزودن از بین دانشجویان" icon="pi pi-user-plus" severity="secondary" outlined @click="showAddFromUserDialog = true" />
|
||||||
|
<Button :label="$t('professors.addProfessor')" icon="pi pi-plus" @click="$router.push('/professors/create')" />
|
||||||
|
</div>
|
||||||
</PermissionGate>
|
</PermissionGate>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
@@ -33,7 +36,13 @@
|
|||||||
|
|
||||||
<Column field="specialization" header="تخصص">
|
<Column field="specialization" header="تخصص">
|
||||||
<template #body="{ data }">
|
<template #body="{ data }">
|
||||||
<Tag :value="data.specialization || 'عمومی'" severity="secondary" />
|
<div class="flex flex-wrap gap-1">
|
||||||
|
<template v-if="data.expertise?.length">
|
||||||
|
<Tag v-for="exp in data.expertise.slice(0, 2)" :key="exp" :value="exp" severity="info" class="text-xs" />
|
||||||
|
<Tag v-if="data.expertise.length > 2" :value="`+${data.expertise.length - 2}`" severity="secondary" class="text-xs" />
|
||||||
|
</template>
|
||||||
|
<Tag v-else :value="data.specialization || 'عمومی'" severity="secondary" />
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
|
|
||||||
@@ -75,6 +84,11 @@
|
|||||||
:loading="isDeleting"
|
:loading="isDeleting"
|
||||||
@confirm="handleDelete"
|
@confirm="handleDelete"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<AddProfessorFromUserDialog
|
||||||
|
v-model="showAddFromUserDialog"
|
||||||
|
@saved="loadData"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -88,6 +102,7 @@ import PageHeader from '@/components/common/PageHeader.vue';
|
|||||||
import DataTableWrapper from '@/components/common/DataTableWrapper.vue';
|
import DataTableWrapper from '@/components/common/DataTableWrapper.vue';
|
||||||
import ConfirmDeleteDialog from '@/components/common/ConfirmDeleteDialog.vue';
|
import ConfirmDeleteDialog from '@/components/common/ConfirmDeleteDialog.vue';
|
||||||
import PermissionGate from '@/components/common/PermissionGate.vue';
|
import PermissionGate from '@/components/common/PermissionGate.vue';
|
||||||
|
import AddProfessorFromUserDialog from '@/components/professors/AddProfessorFromUserDialog.vue';
|
||||||
import Button from 'primevue/button';
|
import Button from 'primevue/button';
|
||||||
import Column from 'primevue/column';
|
import Column from 'primevue/column';
|
||||||
import Tag from 'primevue/tag';
|
import Tag from 'primevue/tag';
|
||||||
@@ -109,6 +124,7 @@ const {
|
|||||||
const deleteDialogVisible = ref(false);
|
const deleteDialogVisible = ref(false);
|
||||||
const selectedProf = ref(null);
|
const selectedProf = ref(null);
|
||||||
const isDeleting = ref(false);
|
const isDeleting = ref(false);
|
||||||
|
const showAddFromUserDialog = ref(false);
|
||||||
|
|
||||||
const confirmDelete = (prof) => {
|
const confirmDelete = (prof) => {
|
||||||
selectedProf.value = prof;
|
selectedProf.value = prof;
|
||||||
|
|||||||
@@ -3,16 +3,15 @@
|
|||||||
<div class="settings-view w-full max-w-4xl mx-auto">
|
<div class="settings-view w-full max-w-4xl mx-auto">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="تنظیمات سیستم"
|
title="تنظیمات سیستم"
|
||||||
subtitle="کانالهای اطلاعرسانی، تنظیمات اعلان، قالبهای پیامک، راهاندازی و وارد کردن دادهها"
|
subtitle="کانالهای اطلاعرسانی، تنظیمات اعلان، راهاندازی و وارد کردن دادهها"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Tabs value="0" class="surface-card border-round-xl border-1 border-color shadow-sm">
|
<Tabs value="0" class="surface-card border-round-xl border-1 border-color shadow-sm">
|
||||||
<TabList :scrollable="true">
|
<TabList :scrollable="true">
|
||||||
<Tab value="0">کانالها</Tab>
|
<Tab value="0">کانالها</Tab>
|
||||||
<Tab value="1">اطلاعرسانی</Tab>
|
<Tab value="1">اطلاعرسانی</Tab>
|
||||||
<Tab value="2">قالبهای پیامک</Tab>
|
<Tab value="2">راهاندازی</Tab>
|
||||||
<Tab value="3">راهاندازی</Tab>
|
<Tab value="3">وارد کردن داده</Tab>
|
||||||
<Tab value="4">وارد کردن داده</Tab>
|
|
||||||
</TabList>
|
</TabList>
|
||||||
|
|
||||||
<TabPanels>
|
<TabPanels>
|
||||||
@@ -161,181 +160,6 @@
|
|||||||
|
|
||||||
<TabPanel value="2">
|
<TabPanel value="2">
|
||||||
<div class="p-4 sm:p-5">
|
<div class="p-4 sm:p-5">
|
||||||
<div class="flex align-items-start gap-3 mb-4">
|
|
||||||
<div class="w-3rem h-3rem border-round-xl flex align-items-center justify-content-center flex-shrink-0 bg-primary-light text-primary">
|
|
||||||
<i class="pi pi-send text-xl"></i>
|
|
||||||
</div>
|
|
||||||
<div class="flex-grow-1">
|
|
||||||
<h2 class="text-lg font-bold text-color m-0 mb-1">قالبهای پیامک</h2>
|
|
||||||
<p class="text-sm text-muted m-0 line-height-3">
|
|
||||||
شناسه قالب و تعداد و نام متغیرهای sms.ir را بر اساس قالب پنل پیامک تنظیم کنید. میتوانید هر تعداد متغیر که میخواهید تعریف یا حذف نمایید.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="isSettingsLoading" class="flex align-items-center gap-2 text-muted text-sm">
|
|
||||||
<i class="pi pi-spin pi-spinner"></i>
|
|
||||||
<span>در حال بارگذاری تنظیمات پیامک…</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="flex flex-column gap-4">
|
|
||||||
<div
|
|
||||||
v-for="template in smsTemplates"
|
|
||||||
:key="template.key"
|
|
||||||
class="p-4 border-round-lg surface-ground border-1 border-color flex flex-column gap-3 transition-all transition-duration-200"
|
|
||||||
:class="{ 'opacity-80': templateForm[template.key] && !templateForm[template.key].enabled }"
|
|
||||||
>
|
|
||||||
<template v-if="templateForm[template.key]">
|
|
||||||
<div class="flex flex-column sm:flex-row sm:align-items-center justify-content-between gap-3 border-bottom-1 border-color pb-3">
|
|
||||||
<div class="flex align-items-center gap-3">
|
|
||||||
<div
|
|
||||||
class="w-2rem h-2rem border-round-lg flex align-items-center justify-content-center flex-shrink-0"
|
|
||||||
:class="templateForm[template.key].enabled ? 'bg-primary-light text-primary' : 'surface-200 text-muted'"
|
|
||||||
>
|
|
||||||
<i class="pi pi-bookmark"></i>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div class="flex align-items-center gap-2 flex-wrap">
|
|
||||||
<label class="font-bold text-base text-color cursor-pointer" :for="`sms-toggle-${template.key}`">
|
|
||||||
{{ template.label }}
|
|
||||||
</label>
|
|
||||||
<Tag
|
|
||||||
:value="templateForm[template.key].enabled ? 'ارسال پیامک: فعال' : 'ارسال پیامک: غیرفعال'"
|
|
||||||
:severity="templateForm[template.key].enabled ? 'success' : 'secondary'"
|
|
||||||
class="text-xs font-semibold"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span class="text-xs text-muted font-mono block mt-1">{{ template.key }}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex align-items-center gap-3 flex-shrink-0">
|
|
||||||
<span class="text-xs font-semibold text-color">
|
|
||||||
{{ templateForm[template.key].enabled ? 'فعال' : 'غیرفعال' }}
|
|
||||||
</span>
|
|
||||||
<InputSwitch
|
|
||||||
:inputId="`sms-toggle-${template.key}`"
|
|
||||||
v-model="templateForm[template.key].enabled"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="!templateForm[template.key].enabled"
|
|
||||||
class="p-2 px-3 border-round surface-card border-1 border-dashed border-color text-xs text-muted flex align-items-center gap-2"
|
|
||||||
>
|
|
||||||
<i class="pi pi-info-circle text-orange-500 flex-shrink-0"></i>
|
|
||||||
<span>ارسال این نوع پیامک غیرفعال است و با وقوع رویداد مربوطه پیامکی ارسال نخواهد شد.</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-column sm:flex-row sm:align-items-center gap-2">
|
|
||||||
<label
|
|
||||||
class="text-sm font-semibold text-color sm:w-10rem flex-shrink-0"
|
|
||||||
:for="`sms-template-${template.key}`"
|
|
||||||
>
|
|
||||||
شناسه قالب sms.ir:
|
|
||||||
</label>
|
|
||||||
<div class="flex-grow-1">
|
|
||||||
<InputText
|
|
||||||
:id="`sms-template-${template.key}`"
|
|
||||||
v-model.trim="templateForm[template.key].templateId"
|
|
||||||
class="w-full text-sm"
|
|
||||||
dir="ltr"
|
|
||||||
inputmode="numeric"
|
|
||||||
placeholder="مثلاً: 123456"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-column gap-3 pt-2">
|
|
||||||
<div class="flex align-items-center justify-content-between">
|
|
||||||
<div>
|
|
||||||
<span class="text-sm font-semibold text-color">متغیرهای ارسالی در قالب</span>
|
|
||||||
<span class="text-xs text-muted block mt-1">متغیرهایی که در پنل sms.ir در متن قالب قرار دادهاید</span>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
label="افزودن متغیر"
|
|
||||||
icon="pi pi-plus"
|
|
||||||
size="small"
|
|
||||||
outlined
|
|
||||||
class="text-xs font-semibold"
|
|
||||||
@click="addVariable(template.key)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="!templateForm[template.key]?.variables || templateForm[template.key]?.variables.length === 0"
|
|
||||||
class="p-3 text-center border-1 border-dashed border-round surface-card text-muted text-xs line-height-3"
|
|
||||||
>
|
|
||||||
هیچ متغیری برای این قالب تعریف نشده است. پیامک بدون متغیر ارسال خواهد شد.
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="flex flex-column gap-2">
|
|
||||||
<div
|
|
||||||
v-for="(variable, vIdx) in templateForm[template.key].variables"
|
|
||||||
:key="variable.id || vIdx"
|
|
||||||
class="flex flex-column md:flex-row md:align-items-center gap-3 p-3 border-round surface-card border-1 border-color"
|
|
||||||
>
|
|
||||||
<div class="flex flex-column gap-1 md:w-16rem flex-shrink-0">
|
|
||||||
<span class="text-xs text-muted font-semibold">مقدار ارسالی از سیستم:</span>
|
|
||||||
<Select
|
|
||||||
v-model="variable.slot"
|
|
||||||
:options="getAvailableSlots(template.key)"
|
|
||||||
optionLabel="label"
|
|
||||||
optionValue="value"
|
|
||||||
placeholder="انتخاب مقدار داده"
|
|
||||||
class="w-full text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex-grow-1 flex flex-column gap-1">
|
|
||||||
<span class="text-xs text-muted font-semibold">نام متغیر در sms.ir:</span>
|
|
||||||
<div class="flex align-items-center gap-2">
|
|
||||||
<InputText
|
|
||||||
v-model.trim="variable.name"
|
|
||||||
class="w-full text-sm"
|
|
||||||
dir="ltr"
|
|
||||||
:placeholder="getSlotDefaultName(template.key, variable.slot) || 'نام متغیر در sms.ir'"
|
|
||||||
autocomplete="off"
|
|
||||||
/>
|
|
||||||
<Tag
|
|
||||||
:value="`#${variable.name || getSlotDefaultName(template.key, variable.slot) || '...'}#`"
|
|
||||||
severity="secondary"
|
|
||||||
class="text-xs flex-shrink-0 font-mono"
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
icon="pi pi-trash"
|
|
||||||
severity="danger"
|
|
||||||
text
|
|
||||||
rounded
|
|
||||||
size="small"
|
|
||||||
class="p-button-sm flex-shrink-0"
|
|
||||||
title="حذف متغیر"
|
|
||||||
@click="removeVariable(template.key, vIdx)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex justify-content-end">
|
|
||||||
<Button
|
|
||||||
label="ذخیره قالبها"
|
|
||||||
icon="pi pi-save"
|
|
||||||
class="font-bold"
|
|
||||||
:loading="isSavingSettings"
|
|
||||||
@click="saveSmsTemplates"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</TabPanel>
|
|
||||||
|
|
||||||
<TabPanel value="3">
|
|
||||||
<div class="p-4 sm:p-5">
|
|
||||||
<div class="flex align-items-start gap-3 mb-4">
|
<div class="flex align-items-start gap-3 mb-4">
|
||||||
<div class="w-3rem h-3rem border-round-xl flex align-items-center justify-content-center flex-shrink-0 bg-primary-light text-primary">
|
<div class="w-3rem h-3rem border-round-xl flex align-items-center justify-content-center flex-shrink-0 bg-primary-light text-primary">
|
||||||
<i class="pi pi-database text-xl"></i>
|
<i class="pi pi-database text-xl"></i>
|
||||||
@@ -376,7 +200,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
|
||||||
<TabPanel value="4">
|
<TabPanel value="3">
|
||||||
<div class="p-4 sm:p-5">
|
<div class="p-4 sm:p-5">
|
||||||
<div class="flex align-items-start gap-3 mb-4">
|
<div class="flex align-items-start gap-3 mb-4">
|
||||||
<div class="w-3rem h-3rem border-round-xl flex align-items-center justify-content-center flex-shrink-0 bg-primary-light text-primary">
|
<div class="w-3rem h-3rem border-round-xl flex align-items-center justify-content-center flex-shrink-0 bg-primary-light text-primary">
|
||||||
|
|||||||
+649
-155
@@ -1,180 +1,449 @@
|
|||||||
<!-- /src/views/users/UserDetailView.vue -->
|
<!-- /src/views/users/UserDetailView.vue -->
|
||||||
<template>
|
<template>
|
||||||
<div class="user-detail-view" v-if="user">
|
<div class="user-detail-view" v-if="profileData">
|
||||||
<PageHeader :title="user.name || ''" :subtitle="`کد ملی: ${toPersianDigits(user.nationalIdCode || user.nationalId)}`">
|
<!-- ── Profile Header & Quick KPI Bar ──────────────────────────────────── -->
|
||||||
<PermissionGate permission="users:update">
|
<div class="surface-card p-4 border-round-xl border-1 border-color shadow-sm mb-4">
|
||||||
<Button :label="$t('app.edit')" icon="pi pi-pencil" severity="warning" @click="$router.push(`/users/edit/${user._id || user.id}`)" />
|
<div class="flex flex-column md:flex-row md:align-items-center justify-content-between gap-4 pb-3 border-bottom-1 border-color">
|
||||||
</PermissionGate>
|
<div class="flex align-items-center gap-3">
|
||||||
<Button label="بازگشت" icon="pi pi-arrow-left" text severity="secondary" @click="$router.push('/users')" />
|
<div class="avatar-box w-4rem h-4rem border-circle bg-primary-100 text-primary flex align-items-center justify-content-center text-2xl font-bold flex-shrink-0 shadow-1">
|
||||||
</PageHeader>
|
{{ getInitials(user.name) }}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="flex align-items-center gap-2 flex-wrap">
|
||||||
|
<h1 class="text-xl md:text-2xl font-bold text-color m-0">{{ user.name }}</h1>
|
||||||
|
<Tag :value="user.role?.name || 'دانشجو'" :severity="getRoleSeverity(user.role?.name)" />
|
||||||
|
<StatusTag :status="user.isActive !== false" />
|
||||||
|
</div>
|
||||||
|
<div class="flex align-items-center gap-3 text-xs text-muted mt-1 flex-wrap">
|
||||||
|
<span v-if="user.uniqueCode" class="font-semibold text-primary">کد کاربری: {{ user.uniqueCode }}</span>
|
||||||
|
<span>کد ملی: <b dir="ltr">{{ toPersianDigits(user.nationalIdCode || user.nationalId || '-') }}</b></span>
|
||||||
|
<span>شماره همراه: <b dir="ltr">{{ toPersianDigits(user.phoneNumber || user.phone || '-') }}</b></span>
|
||||||
|
<span v-if="user.username">نام کاربری: <b dir="ltr">@{{ user.username }}</b></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Main Profile Tabs -->
|
<!-- Action buttons -->
|
||||||
<Tabs value="0" class="surface-card border-round border-1 border-color shadow-sm">
|
<div class="flex align-items-center gap-2 flex-wrap justify-content-end">
|
||||||
|
<PermissionGate permission="users:update">
|
||||||
|
<Button
|
||||||
|
v-if="user.role?.name !== 'Professor'"
|
||||||
|
label="ارتقا به استاد"
|
||||||
|
icon="pi pi-user-plus"
|
||||||
|
severity="help"
|
||||||
|
size="small"
|
||||||
|
@click="showPromoteModal = true"
|
||||||
|
/>
|
||||||
|
</PermissionGate>
|
||||||
|
|
||||||
|
<PermissionGate permission="users:enroll">
|
||||||
|
<Button label="ثبتنام دوره" icon="pi pi-plus" size="small" @click="showEnrollModal = true" />
|
||||||
|
</PermissionGate>
|
||||||
|
|
||||||
|
<PermissionGate permission="users:update">
|
||||||
|
<Button
|
||||||
|
label="پیامک رمز"
|
||||||
|
icon="pi pi-key"
|
||||||
|
severity="secondary"
|
||||||
|
size="small"
|
||||||
|
outlined
|
||||||
|
:loading="isSendingSms"
|
||||||
|
@click="handleResetPasswordSms"
|
||||||
|
/>
|
||||||
|
</PermissionGate>
|
||||||
|
|
||||||
|
<PermissionGate permission="users:update">
|
||||||
|
<Button label="ویرایش" icon="pi pi-pencil" severity="warning" size="small" @click="$router.push(`/users/edit/${user._id || user.id}`)" />
|
||||||
|
</PermissionGate>
|
||||||
|
|
||||||
|
<Button label="بازگشت" icon="pi pi-arrow-left" text severity="secondary" size="small" @click="$router.push('/users')" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick KPI Row -->
|
||||||
|
<div class="grid pt-3 m-0">
|
||||||
|
<div class="col-12 sm:col-6 lg:col-3 p-2">
|
||||||
|
<div class="p-3 border-round-lg surface-ground border-1 border-color flex align-items-center justify-content-between">
|
||||||
|
<div>
|
||||||
|
<span class="text-xs text-muted block mb-1">کل صورتحسابها</span>
|
||||||
|
<span class="text-lg font-bold text-color">{{ toPersianDigits(financialSummary.totalPayable?.toLocaleString()) }} تومان</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-2.5rem h-2.5rem border-round-lg bg-blue-100 text-blue-600 flex align-items-center justify-content-center">
|
||||||
|
<i class="pi pi-receipt text-lg"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 sm:col-6 lg:col-3 p-2">
|
||||||
|
<div class="p-3 border-round-lg surface-ground border-1 border-color flex align-items-center justify-content-between">
|
||||||
|
<div>
|
||||||
|
<span class="text-xs text-muted block mb-1">مجموع پرداختیها</span>
|
||||||
|
<span class="text-lg font-bold text-green-600">{{ toPersianDigits(financialSummary.totalPaid?.toLocaleString()) }} تومان</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-2.5rem h-2.5rem border-round-lg bg-green-100 text-green-600 flex align-items-center justify-content-center">
|
||||||
|
<i class="pi pi-check-circle text-lg"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 sm:col-6 lg:col-3 p-2">
|
||||||
|
<div class="p-3 border-round-lg surface-ground border-1 border-color flex align-items-center justify-content-between">
|
||||||
|
<div>
|
||||||
|
<span class="text-xs text-muted block mb-1">مانده بدهی</span>
|
||||||
|
<span class="text-lg font-bold" :class="financialSummary.remainingDebt > 0 ? 'text-red-500' : 'text-color'">
|
||||||
|
{{ toPersianDigits(financialSummary.remainingDebt?.toLocaleString()) }} تومان
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-2.5rem h-2.5rem border-round-lg" :class="financialSummary.remainingDebt > 0 ? 'bg-red-100 text-red-600' : 'bg-green-100 text-green-600'" flex align-items-center justify-content-center>
|
||||||
|
<i class="pi" :class="financialSummary.remainingDebt > 0 ? 'pi-exclamation-circle' : 'pi-verified'" text-lg></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 sm:col-6 lg:col-3 p-2">
|
||||||
|
<div class="p-3 border-round-lg surface-ground border-1 border-color flex align-items-center justify-content-between">
|
||||||
|
<div>
|
||||||
|
<span class="text-xs text-muted block mb-1">درصد حضور در جلسات</span>
|
||||||
|
<span class="text-lg font-bold text-primary">{{ toPersianDigits(attendanceSummary.attendanceRate || 0) }}٪</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-2.5rem h-2.5rem border-round-lg bg-purple-100 text-purple-600 flex align-items-center justify-content-center">
|
||||||
|
<i class="pi pi-calendar-check text-lg"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Main Tabs ────────────────────────────────────────────────────────── -->
|
||||||
|
<Tabs value="0" class="surface-card border-round-xl border-1 border-color shadow-sm">
|
||||||
<TabList>
|
<TabList>
|
||||||
<Tab value="0">{{ $t('users.tabInfo') }}</Tab>
|
<Tab value="0"><i class="pi pi-chart-pie ml-2"></i>داشبورد و تحلیل نموداری</Tab>
|
||||||
<Tab value="1">{{ $t('users.tabCourses') }}</Tab>
|
<Tab value="1"><i class="pi pi-user ml-2"></i>اطلاعات کاربری</Tab>
|
||||||
<Tab value="2">{{ $t('users.tabSessions') }}</Tab>
|
<Tab value="2"><i class="pi pi-book ml-2"></i>کلاسها و دورهها ({{ toPersianDigits(classes.length) }})</Tab>
|
||||||
<Tab value="3">{{ $t('users.tabPayments') }}</Tab>
|
<Tab value="3"><i class="pi pi-calendar ml-2"></i>حضور و غیاب ({{ toPersianDigits(attendances.length) }})</Tab>
|
||||||
<Tab value="4">گواهینامهها و اسناد</Tab>
|
<Tab value="4"><i class="pi pi-wallet ml-2"></i>امور مالی و فاکتورها ({{ toPersianDigits(payments.length) }})</Tab>
|
||||||
|
<Tab value="5"><i class="pi pi-file ml-2"></i>اسناد و مدارک</Tab>
|
||||||
|
<Tab value="6"><i class="pi pi-clock ml-2"></i>لیست انتظار ({{ toPersianDigits(waitlist.length) }})</Tab>
|
||||||
|
<Tab value="7"><i class="pi pi-history ml-2"></i>سوابق فعالیت و پیامکها</Tab>
|
||||||
</TabList>
|
</TabList>
|
||||||
|
|
||||||
<TabPanels>
|
<TabPanels>
|
||||||
<!-- Tab 1: User Info -->
|
<!-- ── TAB 0: Analytics & Visual Charts ──────────────────────────────── -->
|
||||||
<TabPanel value="0">
|
<TabPanel value="0">
|
||||||
<div class="grid p-3">
|
<div class="p-3">
|
||||||
<div class="col-12 sm:col-6 md:col-4 mb-3">
|
<div class="grid">
|
||||||
<span class="text-muted text-xs block mb-1">نام</span>
|
<!-- Attendance Doughnut Chart -->
|
||||||
<span class="font-bold text-color text-base">{{ user.name }}</span>
|
<div class="col-12 lg:col-6 mb-3">
|
||||||
</div>
|
<div class="surface-ground p-4 border-round-xl border-1 border-color h-full flex flex-column">
|
||||||
<div class="col-12 sm:col-6 md:col-4 mb-3">
|
<div class="flex align-items-center justify-content-between mb-3">
|
||||||
<span class="text-muted text-xs block mb-1">جنسیت</span>
|
<h3 class="text-base font-bold text-color m-0">تحلیل وضعیت حضور و غیاب</h3>
|
||||||
<span class="font-bold text-color text-base">{{ user.gender === 'male' ? 'مرد' : user.gender === 'female' ? 'زن' : '-' }}</span>
|
<Tag :value="`نرخ حضور: ${toPersianDigits(attendanceSummary.attendanceRate || 0)}٪`" severity="success" />
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12 sm:col-6 md:col-4 mb-3">
|
<div class="flex-grow-1 flex align-items-center justify-content-center">
|
||||||
<span class="text-muted text-xs block mb-1">کد ملی</span>
|
<StudentAttendanceChart :summary="attendanceSummary" :height="220" />
|
||||||
<span class="font-bold text-color text-base" dir="ltr">{{ toPersianDigits(user.nationalIdCode || user.nationalId) }}</span>
|
</div>
|
||||||
</div>
|
<div class="grid mt-3 pt-3 border-top-1 border-color text-center text-xs">
|
||||||
<div class="col-12 sm:col-6 md:col-4 mb-3">
|
<div class="col-3"><span class="text-muted block">حاضر</span><b class="text-green-600">{{ toPersianDigits(attendanceSummary.present || 0) }}</b></div>
|
||||||
<span class="text-muted text-xs block mb-1">شماره همراه</span>
|
<div class="col-3"><span class="text-muted block">تاخیر</span><b class="text-orange-500">{{ toPersianDigits(attendanceSummary.late || 0) }}</b></div>
|
||||||
<span class="font-bold text-color text-base" dir="ltr">{{ toPersianDigits(user.phoneNumber || user.phone) }}</span>
|
<div class="col-3"><span class="text-muted block">موجه</span><b class="text-blue-500">{{ toPersianDigits(attendanceSummary.excused || 0) }}</b></div>
|
||||||
</div>
|
<div class="col-3"><span class="text-muted block">غایب</span><b class="text-red-500">{{ toPersianDigits(attendanceSummary.absent || 0) }}</b></div>
|
||||||
<div class="col-12 sm:col-6 md:col-4 mb-3">
|
</div>
|
||||||
<span class="text-muted text-xs block mb-1">پست الکترونیکی</span>
|
</div>
|
||||||
<span class="font-bold text-color text-base">{{ user.email || '-' }}</span>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="col-12 sm:col-6 md:col-4 mb-3">
|
<!-- Payment Status Chart -->
|
||||||
<span class="text-muted text-xs block mb-1">پیامرسان ترجیحی</span>
|
<div class="col-12 lg:col-6 mb-3">
|
||||||
<div class="flex flex-wrap gap-1">
|
<div class="surface-ground p-4 border-round-xl border-1 border-color h-full flex flex-column">
|
||||||
<Tag
|
<div class="flex align-items-center justify-content-between mb-3">
|
||||||
v-for="messenger in (Array.isArray(user.preferredMessenger) ? user.preferredMessenger : (user.preferredMessenger ? [user.preferredMessenger] : ['SMS']))"
|
<h3 class="text-base font-bold text-color m-0">وضعیت صورتحسابها و بدهی</h3>
|
||||||
:key="messenger"
|
<Tag :value="financialSummary.remainingDebt === 0 ? 'تسویه کامل' : 'دارای بدهی'" :severity="financialSummary.remainingDebt === 0 ? 'success' : 'danger'" />
|
||||||
:value="messenger"
|
</div>
|
||||||
severity="info"
|
<div class="flex-grow-1 flex align-items-center justify-content-center">
|
||||||
/>
|
<StudentPaymentStatusChart :breakdown="chartsData.paymentsBreakdown" :height="220" />
|
||||||
|
</div>
|
||||||
|
<div class="grid mt-3 pt-3 border-top-1 border-color text-center text-xs">
|
||||||
|
<div class="col-4"><span class="text-muted block">کل شهریه</span><b>{{ toPersianDigits(financialSummary.totalTuition?.toLocaleString()) }}</b></div>
|
||||||
|
<div class="col-4"><span class="text-muted block">تخفیف کل</span><b class="text-blue-500">{{ toPersianDigits(financialSummary.totalDiscount?.toLocaleString()) }}</b></div>
|
||||||
|
<div class="col-4"><span class="text-muted block">مانده بدهی</span><b class="text-red-500">{{ toPersianDigits(financialSummary.remainingDebt?.toLocaleString()) }}</b></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Transaction Timeline -->
|
||||||
|
<div class="col-12 mb-3">
|
||||||
|
<div class="surface-ground p-4 border-round-xl border-1 border-color">
|
||||||
|
<div class="flex align-items-center justify-content-between mb-3">
|
||||||
|
<h3 class="text-base font-bold text-color m-0">روند پرداختها و تراکنشهای مالی</h3>
|
||||||
|
<span class="text-xs text-muted">مبالغ بر حسب ماه تراکنش</span>
|
||||||
|
</div>
|
||||||
|
<StudentPaymentTimelineChart :timeline="chartsData.monthlyTransactions" :height="200" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Class progress meters -->
|
||||||
|
<div class="col-12" v-if="classes.length">
|
||||||
|
<div class="surface-ground p-4 border-round-xl border-1 border-color">
|
||||||
|
<h3 class="text-base font-bold text-color mb-3">وضعیت پیشرفت در کلاسهای ثبتنامی</h3>
|
||||||
|
<div class="grid">
|
||||||
|
<div v-for="cls in classes" :key="cls._id" class="col-12 md:col-6 mb-2">
|
||||||
|
<div class="surface-card p-3 border-round-lg border-1 border-color">
|
||||||
|
<div class="flex align-items-center justify-content-between mb-2">
|
||||||
|
<span class="font-bold text-sm text-color">{{ cls.name }}</span>
|
||||||
|
<span class="text-xs text-muted">{{ cls.course?.title || '-' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex align-items-center justify-content-between text-xs text-muted mb-1">
|
||||||
|
<span>مدرس: {{ cls.professor ? `${cls.professor.name} ${cls.professor.surname}` : 'تعیین نشده' }}</span>
|
||||||
|
<span>شهریه: {{ toPersianDigits(cls.tuitionFee?.toLocaleString()) }} تومان</span>
|
||||||
|
</div>
|
||||||
|
<ProgressBar :value="cls.numberOfSessions ? Math.min(100, Math.round(((cls.heldSessionsCount || 0) / cls.numberOfSessions) * 100)) : 0" :showValue="false" style="height: 6px;" />
|
||||||
|
<div class="flex justify-content-between text-xs text-muted mt-1">
|
||||||
|
<span>برنامه: {{ cls.days?.length ? `${cls.days.length} روز در هفته` : '-' }} {{ cls.startTime || '' }}</span>
|
||||||
|
<span v-if="cls.numberOfSessions">{{ toPersianDigits(cls.numberOfSessions) }} جلسه</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="col-12 sm:col-6 md:col-4 mb-3">
|
|
||||||
<span class="text-muted text-xs block mb-1">نقش سیستم</span>
|
|
||||||
<Tag :value="user.role?.name || 'دانشجو'" severity="secondary" />
|
|
||||||
</div>
|
|
||||||
<div class="col-12 sm:col-6 md:col-4 mb-3">
|
|
||||||
<span class="text-muted text-xs block mb-1">نام پدر</span>
|
|
||||||
<span class="font-bold text-color text-base">{{ user.fatherName || '-' }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="col-12 sm:col-6 md:col-4 mb-3">
|
|
||||||
<span class="text-muted text-xs block mb-1">تاریخ تولد</span>
|
|
||||||
<span class="font-bold text-color text-base" dir="ltr">{{ user.birthDate ? new Date(user.birthDate).toLocaleDateString('fa-IR') : '-' }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="col-12 sm:col-6 md:col-4 mb-3">
|
|
||||||
<span class="text-muted text-xs block mb-1">شماره شناسنامه</span>
|
|
||||||
<span class="font-bold text-color text-base" dir="ltr">{{ user.birthCertificateNumber || '-' }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="col-12 sm:col-6 md:col-4 mb-3">
|
|
||||||
<span class="text-muted text-xs block mb-1">محل صدور</span>
|
|
||||||
<span class="font-bold text-color text-base">{{ user.placeOfIssue || '-' }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="col-12 sm:col-6 md:col-4 mb-3">
|
|
||||||
<span class="text-muted text-xs block mb-1">کد پستی</span>
|
|
||||||
<span class="font-bold text-color text-base" dir="ltr">{{ user.postalCode || '-' }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="col-12 sm:col-6 md:col-4 mb-3">
|
|
||||||
<span class="text-muted text-xs block mb-1">تحصیلات</span>
|
|
||||||
<span class="font-bold text-color text-base">{{ user.education || '-' }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="col-12 sm:col-6 md:col-4 mb-3">
|
|
||||||
<span class="text-muted text-xs block mb-1">شماره کارت</span>
|
|
||||||
<span class="font-bold text-color text-base" dir="ltr">{{ user.cardNumber || '-' }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="col-12 sm:col-6 md:col-4 mb-3">
|
|
||||||
<span class="text-muted text-xs block mb-1">شماره شبا (IBAN)</span>
|
|
||||||
<span class="font-bold text-color text-base" dir="ltr">{{ user.shabaNumber || user.iban || '-' }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="col-12 mb-3">
|
|
||||||
<span class="text-muted text-xs block mb-1">آدرس سکونت</span>
|
|
||||||
<span class="text-color text-sm">{{ user.address || '-' }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="col-12 mb-3" v-if="user.adminNotes?.length">
|
|
||||||
<span class="text-muted text-xs block mb-1">یادداشتهای ادمین</span>
|
|
||||||
<ul class="m-0 pr-3 text-sm line-height-3">
|
|
||||||
<li v-for="(note, i) in user.adminNotes" :key="i">{{ note }}</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
|
||||||
<!-- Tab 2: Enrolled Courses -->
|
<!-- ── TAB 1: User Info ──────────────────────────────────────────────── -->
|
||||||
<TabPanel value="1">
|
<TabPanel value="1">
|
||||||
|
<div class="p-3">
|
||||||
|
<h3 class="text-base font-bold text-primary mb-3 pb-2 border-bottom-1 border-color">اطلاعات هویتی و شناسنامهای</h3>
|
||||||
|
<div class="grid mb-4">
|
||||||
|
<div class="col-12 sm:col-6 md:col-4 mb-3">
|
||||||
|
<span class="text-muted text-xs block mb-1">نام و نام خانوادگی</span>
|
||||||
|
<span class="font-bold text-color text-base">{{ user.name }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 sm:col-6 md:col-4 mb-3">
|
||||||
|
<span class="text-muted text-xs block mb-1">جنسیت</span>
|
||||||
|
<span class="font-bold text-color text-base">{{ user.gender === 'male' ? 'مرد' : user.gender === 'female' ? 'زن' : '-' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 sm:col-6 md:col-4 mb-3">
|
||||||
|
<span class="text-muted text-xs block mb-1">کد ملی</span>
|
||||||
|
<span class="font-bold text-color text-base" dir="ltr">{{ toPersianDigits(user.nationalIdCode || user.nationalId) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 sm:col-6 md:col-4 mb-3">
|
||||||
|
<span class="text-muted text-xs block mb-1">شماره شناسنامه</span>
|
||||||
|
<span class="font-bold text-color text-base" dir="ltr">{{ user.birthCertificateNumber || '-' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 sm:col-6 md:col-4 mb-3">
|
||||||
|
<span class="text-muted text-xs block mb-1">تاریخ تولد</span>
|
||||||
|
<span class="font-bold text-color text-base" dir="ltr">{{ user.birthDate ? formatJalali(user.birthDate) : '-' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 sm:col-6 md:col-4 mb-3">
|
||||||
|
<span class="text-muted text-xs block mb-1">نام پدر</span>
|
||||||
|
<span class="font-bold text-color text-base">{{ user.fatherName || '-' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 sm:col-6 md:col-4 mb-3">
|
||||||
|
<span class="text-muted text-xs block mb-1">محل صدور شناسنامه</span>
|
||||||
|
<span class="font-bold text-color text-base">{{ user.placeOfIssue || '-' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="text-base font-bold text-primary mb-3 pb-2 border-bottom-1 border-color">اطلاعات تماس و پیامرسانها</h3>
|
||||||
|
<div class="grid mb-4">
|
||||||
|
<div class="col-12 sm:col-6 md:col-4 mb-3">
|
||||||
|
<span class="text-muted text-xs block mb-1">شماره همراه</span>
|
||||||
|
<span class="font-bold text-color text-base" dir="ltr">{{ toPersianDigits(user.phoneNumber || user.phone) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 sm:col-6 md:col-4 mb-3">
|
||||||
|
<span class="text-muted text-xs block mb-1">پست الکترونیکی (ایمیل)</span>
|
||||||
|
<span class="font-bold text-color text-base">{{ user.email || '-' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 sm:col-6 md:col-4 mb-3">
|
||||||
|
<span class="text-muted text-xs block mb-1">تلفن همراه والدین</span>
|
||||||
|
<span class="font-bold text-color text-base" dir="ltr">{{ toPersianDigits(user.parentPhoneNumber || '-') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 sm:col-6 md:col-4 mb-3">
|
||||||
|
<span class="text-muted text-xs block mb-1">پیامرسانهای ترجیحی</span>
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
<Tag
|
||||||
|
v-for="messenger in (Array.isArray(user.preferredMessenger) ? user.preferredMessenger : (user.preferredMessenger ? [user.preferredMessenger] : ['SMS']))"
|
||||||
|
:key="messenger"
|
||||||
|
:value="messenger"
|
||||||
|
severity="info"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 sm:col-6 md:col-4 mb-3">
|
||||||
|
<span class="text-muted text-xs block mb-1">کد پستی</span>
|
||||||
|
<span class="font-bold text-color text-base" dir="ltr">{{ user.postalCode || '-' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 mb-3">
|
||||||
|
<span class="text-muted text-xs block mb-1">آدرس سکونت</span>
|
||||||
|
<span class="text-color text-sm">{{ user.address || '-' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="text-base font-bold text-primary mb-3 pb-2 border-bottom-1 border-color">اطلاعات مالی و تحصیلی</h3>
|
||||||
|
<div class="grid mb-4">
|
||||||
|
<div class="col-12 sm:col-6 md:col-4 mb-3">
|
||||||
|
<span class="text-muted text-xs block mb-1">تحصیلات و رشته</span>
|
||||||
|
<span class="font-bold text-color text-base">{{ user.education || '-' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 sm:col-6 md:col-4 mb-3">
|
||||||
|
<span class="text-muted text-xs block mb-1">شماره کارت بانکی</span>
|
||||||
|
<span class="font-bold text-color text-base" dir="ltr">{{ user.cardNumber || '-' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 sm:col-6 md:col-4 mb-3">
|
||||||
|
<span class="text-muted text-xs block mb-1">شماره شبا (IBAN)</span>
|
||||||
|
<span class="font-bold text-color text-base" dir="ltr">{{ user.shabaNumber || user.iban || '-' }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="user.adminNotes?.length">
|
||||||
|
<h3 class="text-base font-bold text-primary mb-3 pb-2 border-bottom-1 border-color">یادداشتهای مدیریت</h3>
|
||||||
|
<div class="surface-ground p-3 border-round-lg border-1 border-color">
|
||||||
|
<ul class="m-0 pr-3 text-sm line-height-3">
|
||||||
|
<li v-for="(note, i) in user.adminNotes" :key="i">{{ note }}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TabPanel>
|
||||||
|
|
||||||
|
<!-- ── TAB 2: Enrolled Classes & Courses ─────────────────────────────── -->
|
||||||
|
<TabPanel value="2">
|
||||||
<div class="p-3">
|
<div class="p-3">
|
||||||
<div class="flex align-items-center justify-content-between mb-3">
|
<div class="flex align-items-center justify-content-between mb-3">
|
||||||
<h3 class="text-lg font-bold m-0">دورههای ثبتنام شده</h3>
|
<h3 class="text-base font-bold text-color m-0">کلاسهای ثبتنام شده دانشجو</h3>
|
||||||
<PermissionGate permission="users:enroll">
|
<PermissionGate permission="users:enroll">
|
||||||
<Button label="ثبتنام در دوره جدید" icon="pi pi-plus" size="small" @click="showEnrollModal = true" />
|
<Button label="ثبتنام در دوره جدید" icon="pi pi-plus" size="small" @click="showEnrollModal = true" />
|
||||||
</PermissionGate>
|
</PermissionGate>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DataTable :value="enrolledCourses" class="p-datatable-sm text-sm">
|
<DataTable :value="classes" class="p-datatable-sm text-sm" responsiveLayout="scroll">
|
||||||
<Column field="title" header="عنوان دوره" />
|
<template #empty>
|
||||||
<Column field="type" header="نوع">
|
<div class="p-4 text-center text-muted text-sm">دانشجو در کلاسی ثبتنام نکرده است.</div>
|
||||||
|
</template>
|
||||||
|
<Column field="name" header="نام کلاس">
|
||||||
<template #body="{ data }">
|
<template #body="{ data }">
|
||||||
<Tag :value="data.type === 'Private' ? 'خصوصی' : 'عمومی'" :severity="data.type === 'Private' ? 'warning' : 'info'" />
|
<span class="font-bold text-color">{{ data.name }}</span>
|
||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
<Column field="price" header="شهریه">
|
<Column field="course.title" header="دوره">
|
||||||
<template #body="{ data }">
|
<template #body="{ data }">
|
||||||
{{ toPersianDigits(data.price?.toLocaleString()) }} تومان
|
<span>{{ data.course?.title || '-' }}</span>
|
||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
<Column field="enrollDate" header="تاریخ ثبتنام">
|
<Column field="professor" header="مدرس">
|
||||||
<template #body="{ data }">
|
<template #body="{ data }">
|
||||||
{{ formatJalali(data.enrollDate || data.createdAt) }}
|
<span>{{ data.professor ? `${data.professor.name} ${data.professor.surname}` : '-' }}</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="tuitionFee" header="شهریه">
|
||||||
|
<template #body="{ data }">
|
||||||
|
{{ toPersianDigits((data.tuitionFee || 0).toLocaleString()) }} تومان
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="numberOfSessions" header="تعداد جلسات">
|
||||||
|
<template #body="{ data }">
|
||||||
|
{{ toPersianDigits(data.numberOfSessions || '-') }}
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="startDate" header="تاریخ شروع">
|
||||||
|
<template #body="{ data }">
|
||||||
|
{{ data.startDate ? formatJalali(data.startDate) : '-' }}
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="isActive" header="وضعیت">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<StatusTag :status="data.isActive !== false" />
|
||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
</DataTable>
|
</DataTable>
|
||||||
</div>
|
</div>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
|
||||||
<!-- Tab 3: Sessions & Attendance -->
|
<!-- ── TAB 3: Attendance History ─────────────────────────────────────── -->
|
||||||
<TabPanel value="2">
|
|
||||||
<div class="p-3">
|
|
||||||
<h3 class="text-lg font-bold mb-3">تاریخچه حضور و غیاب دانشجو</h3>
|
|
||||||
<DataTable :value="attendances" class="p-datatable-sm text-sm">
|
|
||||||
<Column field="sessionTitle" header="عنوان جلسه / دوره" />
|
|
||||||
<Column field="date" header="تاریخ جلسه">
|
|
||||||
<template #body="{ data }">
|
|
||||||
{{ formatJalali(data.date) }}
|
|
||||||
</template>
|
|
||||||
</Column>
|
|
||||||
<Column field="status" header="وضعیت حضور">
|
|
||||||
<template #body="{ data }">
|
|
||||||
<Tag :value="data.status" :severity="getAttendanceSeverity(data.status)" />
|
|
||||||
</template>
|
|
||||||
</Column>
|
|
||||||
<Column field="note" header="یادداشت" />
|
|
||||||
</DataTable>
|
|
||||||
</div>
|
|
||||||
</TabPanel>
|
|
||||||
|
|
||||||
<!-- Tab 4: Payments -->
|
|
||||||
<TabPanel value="3">
|
<TabPanel value="3">
|
||||||
<div class="p-3">
|
<div class="p-3">
|
||||||
<div class="flex align-items-center justify-content-between mb-3">
|
<div class="flex align-items-center justify-content-between mb-3">
|
||||||
<h3 class="text-lg font-bold m-0">صورتحسابها و پرداختها</h3>
|
<h3 class="text-base font-bold text-color m-0">سوابق حضور و غیاب دانشجو در جلسات</h3>
|
||||||
<PermissionGate permission="payments:update">
|
<div class="flex gap-2">
|
||||||
|
<Tag :value="`حاضر: ${toPersianDigits(attendanceSummary.present || 0)}`" severity="success" />
|
||||||
|
<Tag :value="`غایب: ${toPersianDigits(attendanceSummary.absent || 0)}`" severity="danger" />
|
||||||
|
<Tag :value="`تاخیر: ${toPersianDigits(attendanceSummary.late || 0)}`" severity="warning" />
|
||||||
|
<Tag :value="`موجه: ${toPersianDigits(attendanceSummary.excused || 0)}`" severity="info" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DataTable :value="attendances" class="p-datatable-sm text-sm" paginator :rows="10" responsiveLayout="scroll">
|
||||||
|
<template #empty>
|
||||||
|
<div class="p-4 text-center text-muted text-sm">هیچ سابقه حضوری برای این دانشجو یافت نشد.</div>
|
||||||
|
</template>
|
||||||
|
<Column field="topic" header="عنوان جلسه / مبحث">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<span class="font-bold text-color">{{ data.topic || data.class?.name || 'جلسه آموزشی' }}</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="course.title" header="دوره / کلاس">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<span>{{ data.course?.title || data.class?.name || '-' }}</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="day" header="تاریخ جلسه">
|
||||||
|
<template #body="{ data }">
|
||||||
|
{{ formatJalali(data.day) }}
|
||||||
|
<span v-if="data.startTime" class="text-xs text-muted mr-1" dir="ltr">({{ data.startTime }})</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="attendanceStatus" header="وضعیت حضور">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<Tag :value="getAttendanceLabel(data.attendanceStatus)" :severity="getAttendanceSeverity(data.attendanceStatus)" />
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="attendanceNote" header="یادداشت مدرس / حضور">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<span class="text-muted text-xs">{{ data.attendanceNote || '-' }}</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
</DataTable>
|
||||||
|
</div>
|
||||||
|
</TabPanel>
|
||||||
|
|
||||||
|
<!-- ── TAB 4: Invoices & Transactions ───────────────────────────────── -->
|
||||||
|
<TabPanel value="4">
|
||||||
|
<div class="p-3">
|
||||||
|
<div class="flex align-items-center justify-content-between mb-3">
|
||||||
|
<h3 class="text-base font-bold text-color m-0">صورتحسابها و فاکتورهای آموزشی</h3>
|
||||||
|
<PermissionGate permission="payments:create">
|
||||||
<Button label="ثبت پرداخت جدید" icon="pi pi-wallet" size="small" severity="success" @click="$router.push('/payments')" />
|
<Button label="ثبت پرداخت جدید" icon="pi pi-wallet" size="small" severity="success" @click="$router.push('/payments')" />
|
||||||
</PermissionGate>
|
</PermissionGate>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<DataTable :value="payments" class="p-datatable-sm text-sm">
|
<!-- Invoices Table -->
|
||||||
|
<DataTable :value="payments" class="p-datatable-sm text-sm mb-4" responsiveLayout="scroll">
|
||||||
|
<template #empty>
|
||||||
|
<div class="p-4 text-center text-muted text-sm">هیچ صورتحسابی برای این دانشجو ثبت نشده است.</div>
|
||||||
|
</template>
|
||||||
|
<Column field="uniqueCode" header="کد فاکتور">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<span class="font-bold text-primary" dir="ltr">{{ data.uniqueCode || '-' }}</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="course" header="دوره / کلاس">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<span>{{ data.course?.title || data.classes?.[0]?.name || 'شهریه دوره' }}</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
<Column field="amount" header="مبلغ کل">
|
<Column field="amount" header="مبلغ کل">
|
||||||
<template #body="{ data }">
|
<template #body="{ data }">
|
||||||
<div>{{ toPersianDigits(getPayableAmount(data).toLocaleString()) }} تومان</div>
|
{{ toPersianDigits(data.amount?.toLocaleString()) }} تومان
|
||||||
<small v-if="data.discount" class="text-muted text-xs">
|
</template>
|
||||||
تخفیف {{ toPersianDigits((data.discount || 0).toLocaleString()) }} تومان
|
</Column>
|
||||||
</small>
|
<Column field="discount" header="تخفیف">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<span :class="data.discount ? 'text-blue-500 font-bold' : ''">{{ toPersianDigits((data.discount || 0).toLocaleString()) }} تومان</span>
|
||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
<Column field="paidAmount" header="پرداختی">
|
<Column field="paidAmount" header="پرداختی">
|
||||||
<template #body="{ data }">
|
<template #body="{ data }">
|
||||||
{{ toPersianDigits(data.paidAmount?.toLocaleString()) }} تومان
|
<span class="text-green-600 font-bold">{{ toPersianDigits((data.paidAmount || 0).toLocaleString()) }} تومان</span>
|
||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
<Column field="status" header="وضعیت">
|
<Column field="status" header="وضعیت">
|
||||||
@@ -182,26 +451,166 @@
|
|||||||
<StatusTag :status="data.status" type="payment" />
|
<StatusTag :status="data.status" type="payment" />
|
||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
<Column field="dueDate" header="تاریخ سررسید">
|
<Column field="dueDate" header="سررسید">
|
||||||
<template #body="{ data }">
|
<template #body="{ data }">
|
||||||
{{ formatJalali(data.dueDate) }}
|
{{ data.dueDate ? formatJalali(data.dueDate) : '-' }}
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
</DataTable>
|
||||||
|
|
||||||
|
<!-- Bank Transactions Section -->
|
||||||
|
<h3 class="text-base font-bold text-color mb-3 pt-3 border-top-1 border-color">تراکنشهای بانکی ثبت شده</h3>
|
||||||
|
<DataTable :value="transactions" class="p-datatable-sm text-sm" responsiveLayout="scroll">
|
||||||
|
<template #empty>
|
||||||
|
<div class="p-4 text-center text-muted text-sm">هیچ تراکنش بانکی ثبت نشده است.</div>
|
||||||
|
</template>
|
||||||
|
<Column field="date" header="تاریخ تراکنش">
|
||||||
|
<template #body="{ data }">
|
||||||
|
{{ formatJalali(data.date || data.createdAt) }}
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="amount" header="مبلغ">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<b class="text-green-600">{{ toPersianDigits((data.amount || 0).toLocaleString()) }} تومان</b>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="method" header="روش پرداخت">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<Tag :value="getMethodLabel(data.method)" severity="secondary" />
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="receiptNumber" header="شماره فیش / پیگیری">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<span dir="ltr">{{ toPersianDigits(data.receiptNumber || '-') }}</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="status" header="وضعیت">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<StatusTag :status="data.status" type="payment" />
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="notes" header="توضیحات">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<span class="text-xs text-muted">{{ data.notes || '-' }}</span>
|
||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
</DataTable>
|
</DataTable>
|
||||||
</div>
|
</div>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
|
||||||
<!-- Tab 5: Certificates & Documents -->
|
<!-- ── TAB 5: Certificates & Documents ──────────────────────────────── -->
|
||||||
<TabPanel value="4">
|
<TabPanel value="5">
|
||||||
<div class="p-3">
|
<div class="p-3">
|
||||||
|
<div v-if="certificates.length" class="mb-4">
|
||||||
|
<h3 class="text-base font-bold text-color mb-3">گواهینامههای رسمی صادر شده</h3>
|
||||||
|
<div class="grid">
|
||||||
|
<div v-for="cert in certificates" :key="cert._id" class="col-12 sm:col-6 md:col-4">
|
||||||
|
<div class="surface-ground p-3 border-round-lg border-1 border-color flex flex-column gap-2">
|
||||||
|
<div class="flex align-items-center justify-content-between">
|
||||||
|
<span class="font-bold text-sm text-color">{{ cert.title }}</span>
|
||||||
|
<Tag value="رسمی" severity="success" class="text-xs" />
|
||||||
|
</div>
|
||||||
|
<span class="text-xs text-muted">دوره: {{ cert.course?.title || '-' }}</span>
|
||||||
|
<span class="text-xs text-muted">تاریخ صدور: {{ formatJalali(cert.issuedAt) }}</span>
|
||||||
|
<a v-if="cert.fileUrl" :href="cert.fileUrl" target="_blank" class="p-button p-button-sm p-button-outlined mt-2 text-center text-xs">
|
||||||
|
<i class="pi pi-download ml-1"></i> دانلود گواهینامه
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="text-base font-bold text-color mb-3 pt-2">مدارک و فایلهای پیوست دانشجو</h3>
|
||||||
<UserFilesSection :user-id="String(userId)" />
|
<UserFilesSection :user-id="String(userId)" />
|
||||||
</div>
|
</div>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
|
||||||
|
<!-- ── TAB 6: Waitlist History ──────────────────────────────────────── -->
|
||||||
|
<TabPanel value="6">
|
||||||
|
<div class="p-3">
|
||||||
|
<h3 class="text-base font-bold text-color mb-3">سوابق ثبتنام در لیست انتظار</h3>
|
||||||
|
<DataTable :value="waitlist" class="p-datatable-sm text-sm" responsiveLayout="scroll">
|
||||||
|
<template #empty>
|
||||||
|
<div class="p-4 text-center text-muted text-sm">هیچ درخواستی در لیست انتظار ثبت نشده است.</div>
|
||||||
|
</template>
|
||||||
|
<Column field="course.title" header="دوره مورد تقاضا" />
|
||||||
|
<Column field="class.name" header="کلاس تخصیصیافته">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<span>{{ data.class?.name || 'هنوز تخصیص نیافته' }}</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="registeredAt" header="تاریخ ثبت">
|
||||||
|
<template #body="{ data }">
|
||||||
|
{{ formatJalali(data.registeredAt || data.createdAt) }}
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="status" header="وضعیت">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<Tag :value="getWaitlistLabel(data.status)" :severity="getWaitlistSeverity(data.status)" />
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
</DataTable>
|
||||||
|
</div>
|
||||||
|
</TabPanel>
|
||||||
|
|
||||||
|
<!-- ── TAB 7: Activity Logs & Notifications ─────────────────────────── -->
|
||||||
|
<TabPanel value="7">
|
||||||
|
<div class="p-3">
|
||||||
|
<!-- Notifications sent -->
|
||||||
|
<h3 class="text-base font-bold text-color mb-3">پیامکها و اطلاعیههای ارسالی به دانشجو</h3>
|
||||||
|
<DataTable :value="notifications" class="p-datatable-sm text-sm mb-4" paginator :rows="5" responsiveLayout="scroll">
|
||||||
|
<template #empty>
|
||||||
|
<div class="p-4 text-center text-muted text-sm">هیچ اطلاعیهای ارسال نشده است.</div>
|
||||||
|
</template>
|
||||||
|
<Column field="channel" header="کانال">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<Tag :value="data.channel === 'sms' ? 'پیامک' : data.channel === 'email' ? 'ایمیل' : data.channel" severity="info" />
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="subject" header="موضوع" />
|
||||||
|
<Column field="body" header="متن پیام">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<span class="text-xs line-height-3">{{ data.body }}</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="status" header="وضعیت">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<Tag :value="data.status" :severity="data.status === 'delivered' || data.status === 'sent' ? 'success' : 'warning'" />
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="createdAt" header="تاریخ ارسال">
|
||||||
|
<template #body="{ data }">
|
||||||
|
{{ formatJalali(data.createdAt) }}
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
</DataTable>
|
||||||
|
|
||||||
|
<!-- Activity logs -->
|
||||||
|
<h3 class="text-base font-bold text-color mb-3 pt-3 border-top-1 border-color">لاگهای فعالیتهای اخیر</h3>
|
||||||
|
<DataTable :value="activityLogs" class="p-datatable-sm text-sm" paginator :rows="5" responsiveLayout="scroll">
|
||||||
|
<template #empty>
|
||||||
|
<div class="p-4 text-center text-muted text-sm">هیچ لاگ فعالیتی یافت نشد.</div>
|
||||||
|
</template>
|
||||||
|
<Column field="action" header="عملیات">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<Tag :value="data.action" severity="secondary" />
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
<Column field="resource" header="بخش" />
|
||||||
|
<Column field="description" header="توضیحات" />
|
||||||
|
<Column field="createdAt" header="زمان">
|
||||||
|
<template #body="{ data }">
|
||||||
|
{{ formatJalali(data.createdAt) }}
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
</DataTable>
|
||||||
|
</div>
|
||||||
|
</TabPanel>
|
||||||
</TabPanels>
|
</TabPanels>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
<!-- Enroll Modal -->
|
<!-- Enroll Course Modal -->
|
||||||
<Dialog v-model:visible="showEnrollModal" header="ثبتنام دانشجو در دوره جدید" modal :style="{ width: '450px' }">
|
<Dialog v-model:visible="showEnrollModal" header="ثبتنام دانشجو در دوره آموزشی" modal :style="{ width: '450px' }">
|
||||||
<div class="flex flex-column gap-3 py-2">
|
<div class="flex flex-column gap-3 py-2">
|
||||||
<label class="font-semibold text-sm">انتخاب دوره آموزشی</label>
|
<label class="font-semibold text-sm">انتخاب دوره آموزشی</label>
|
||||||
<Dropdown
|
<Dropdown
|
||||||
@@ -218,21 +627,30 @@
|
|||||||
<Button label="تایید ثبتنام" icon="pi pi-check" :loading="isEnrolling" @click="handleEnroll" />
|
<Button label="تایید ثبتنام" icon="pi pi-check" :loading="isEnrolling" @click="handleEnroll" />
|
||||||
</template>
|
</template>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
|
<!-- Promote to Professor Dialog -->
|
||||||
|
<AddProfessorFromUserDialog
|
||||||
|
v-model="showPromoteModal"
|
||||||
|
:preselected-user-id="String(userId)"
|
||||||
|
@saved="fetchFullProfile"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue';
|
import { ref, computed, onMounted } from 'vue';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
import { userApi } from '@/api/userApi';
|
import { userApi } from '@/api/userApi';
|
||||||
import { courseApi } from '@/api/courseApi';
|
import { courseApi } from '@/api/courseApi';
|
||||||
import { usePersianDate } from '@/composables/usePersianDate';
|
import { usePersianDate } from '@/composables/usePersianDate';
|
||||||
import { useToast } from '@/composables/useToast';
|
import { useToast } from '@/composables/useToast';
|
||||||
import { getPayableAmount } from '@/utils/paymentAmount';
|
|
||||||
import PageHeader from '@/components/common/PageHeader.vue';
|
|
||||||
import PermissionGate from '@/components/common/PermissionGate.vue';
|
import PermissionGate from '@/components/common/PermissionGate.vue';
|
||||||
import StatusTag from '@/components/common/StatusTag.vue';
|
import StatusTag from '@/components/common/StatusTag.vue';
|
||||||
import UserFilesSection from '@/components/uploader/UserFilesSection.vue';
|
import UserFilesSection from '@/components/uploader/UserFilesSection.vue';
|
||||||
|
import AddProfessorFromUserDialog from '@/components/professors/AddProfessorFromUserDialog.vue';
|
||||||
|
import StudentAttendanceChart from '@/components/users/charts/StudentAttendanceChart.vue';
|
||||||
|
import StudentPaymentStatusChart from '@/components/users/charts/StudentPaymentStatusChart.vue';
|
||||||
|
import StudentPaymentTimelineChart from '@/components/users/charts/StudentPaymentTimelineChart.vue';
|
||||||
import Tabs from 'primevue/tabs';
|
import Tabs from 'primevue/tabs';
|
||||||
import TabList from 'primevue/tablist';
|
import TabList from 'primevue/tablist';
|
||||||
import Tab from 'primevue/tab';
|
import Tab from 'primevue/tab';
|
||||||
@@ -244,35 +662,88 @@ import DataTable from 'primevue/datatable';
|
|||||||
import Column from 'primevue/column';
|
import Column from 'primevue/column';
|
||||||
import Dialog from 'primevue/dialog';
|
import Dialog from 'primevue/dialog';
|
||||||
import Dropdown from 'primevue/select';
|
import Dropdown from 'primevue/select';
|
||||||
|
import ProgressBar from 'primevue/progressbar';
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const userId = route.params.id;
|
const userId = route.params.id;
|
||||||
const { toPersianDigits, formatJalali } = usePersianDate();
|
const { toPersianDigits, formatJalali } = usePersianDate();
|
||||||
const { showSuccess, showError } = useToast();
|
const { showSuccess, showError } = useToast();
|
||||||
|
|
||||||
const user = ref(null);
|
const profileData = ref(null);
|
||||||
const enrolledCourses = ref([]);
|
const user = computed(() => profileData.value?.user || {});
|
||||||
const attendances = ref([]);
|
const classes = computed(() => profileData.value?.classes || []);
|
||||||
const payments = ref([]);
|
const attendances = computed(() => profileData.value?.attendances || []);
|
||||||
|
const payments = computed(() => profileData.value?.payments || []);
|
||||||
|
const transactions = computed(() => profileData.value?.transactions || []);
|
||||||
|
const certificates = computed(() => profileData.value?.certificates || []);
|
||||||
|
const waitlist = computed(() => profileData.value?.waitlist || []);
|
||||||
|
const notifications = computed(() => profileData.value?.notifications || []);
|
||||||
|
const activityLogs = computed(() => profileData.value?.activityLogs || []);
|
||||||
|
const financialSummary = computed(() => profileData.value?.financialSummary || {});
|
||||||
|
const attendanceSummary = computed(() => profileData.value?.attendanceSummary || {});
|
||||||
|
const chartsData = computed(() => profileData.value?.chartsData || {});
|
||||||
|
|
||||||
const showEnrollModal = ref(false);
|
const showEnrollModal = ref(false);
|
||||||
|
const showPromoteModal = ref(false);
|
||||||
const allCourses = ref([]);
|
const allCourses = ref([]);
|
||||||
const selectedCourseId = ref(null);
|
const selectedCourseId = ref(null);
|
||||||
const isEnrolling = ref(false);
|
const isEnrolling = ref(false);
|
||||||
|
const isSendingSms = ref(false);
|
||||||
|
|
||||||
const getAttendanceSeverity = (status) => {
|
const getInitials = (name) => {
|
||||||
if (status === 'حاضر' || status === 'present') return 'success';
|
if (!name) return 'U';
|
||||||
if (status === 'غایب' || status === 'absent') return 'danger';
|
const parts = name.trim().split(/\s+/);
|
||||||
if (status === 'تاخیر' || status === 'late') return 'warning';
|
return parts.length > 1 ? `${parts[0][0]}${parts[1][0]}` : parts[0][0];
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRoleSeverity = (roleName) => {
|
||||||
|
if (roleName === 'SuperAdmin' || roleName === 'Admin') return 'danger';
|
||||||
|
if (roleName === 'Professor') return 'help';
|
||||||
|
if (roleName === 'Secretary') return 'warning';
|
||||||
return 'info';
|
return 'info';
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchUserDetail = async () => {
|
const getAttendanceLabel = (status) => {
|
||||||
|
if (status === 'present') return 'حاضر';
|
||||||
|
if (status === 'absent') return 'غایب';
|
||||||
|
if (status === 'late') return 'تاخیر';
|
||||||
|
if (status === 'excused') return 'موجه';
|
||||||
|
return status || 'تعیین نشده';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getAttendanceSeverity = (status) => {
|
||||||
|
if (status === 'present') return 'success';
|
||||||
|
if (status === 'absent') return 'danger';
|
||||||
|
if (status === 'late') return 'warning';
|
||||||
|
if (status === 'excused') return 'info';
|
||||||
|
return 'secondary';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getMethodLabel = (method) => {
|
||||||
|
if (method === 'card') return 'کارت به کارت';
|
||||||
|
if (method === 'online') return 'پرداخت آنلاین';
|
||||||
|
if (method === 'cash') return 'نقدی';
|
||||||
|
return method || '-';
|
||||||
|
};
|
||||||
|
|
||||||
|
const getWaitlistLabel = (status) => {
|
||||||
|
if (status === 'waiting') return 'در انتظار';
|
||||||
|
if (status === 'enrolled') return 'ثبتنام شده';
|
||||||
|
if (status === 'cancelled') return 'لغو شده';
|
||||||
|
return status;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getWaitlistSeverity = (status) => {
|
||||||
|
if (status === 'waiting') return 'warning';
|
||||||
|
if (status === 'enrolled') return 'success';
|
||||||
|
if (status === 'cancelled') return 'danger';
|
||||||
|
return 'secondary';
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchFullProfile = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await userApi.getOne(userId);
|
const res = await userApi.getFullProfile(userId);
|
||||||
user.value = res.data || res;
|
profileData.value = res.data || res;
|
||||||
enrolledCourses.value = user.value.enrolledCourses || user.value.courses || [];
|
|
||||||
payments.value = user.value.payments || [];
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showError(err);
|
showError(err);
|
||||||
}
|
}
|
||||||
@@ -295,7 +766,7 @@ const handleEnroll = async () => {
|
|||||||
await userApi.enroll(userId, { courseId: selectedCourseId.value });
|
await userApi.enroll(userId, { courseId: selectedCourseId.value });
|
||||||
showSuccess('دانشجو با موفقیت در دوره ثبتنام شد');
|
showSuccess('دانشجو با موفقیت در دوره ثبتنام شد');
|
||||||
showEnrollModal.value = false;
|
showEnrollModal.value = false;
|
||||||
fetchUserDetail();
|
fetchFullProfile();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showError(err);
|
showError(err);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -303,8 +774,31 @@ const handleEnroll = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleResetPasswordSms = async () => {
|
||||||
|
isSendingSms.value = true;
|
||||||
|
try {
|
||||||
|
const res = await userApi.resetPasswordAndSms(userId);
|
||||||
|
const data = res.data || res;
|
||||||
|
if (data.smsSent) {
|
||||||
|
showSuccess(`رمز عبور بازنشانی و پیامک به شماره ${data.phoneNumber} ارسال شد`);
|
||||||
|
} else {
|
||||||
|
showSuccess(`رمز عبور جدید: ${data.generatedCredentials?.password || ''}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
showError(err);
|
||||||
|
} finally {
|
||||||
|
isSendingSms.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchUserDetail();
|
fetchFullProfile();
|
||||||
fetchAllCourses();
|
fetchAllCourses();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.avatar-box {
|
||||||
|
border: 2px solid var(--p-primary-color);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -2,11 +2,53 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="user-list-view">
|
<div class="user-list-view">
|
||||||
<PageHeader :title="$t('users.title')" :subtitle="$t('users.subtitle')">
|
<PageHeader :title="$t('users.title')" :subtitle="$t('users.subtitle')">
|
||||||
<PermissionGate permission="users:create">
|
<div class="flex gap-2">
|
||||||
<Button :label="$t('users.addUser')" icon="pi pi-user-plus" @click="$router.push('/users/create')" />
|
<PermissionGate permission="professors:create">
|
||||||
</PermissionGate>
|
<Button label="افزودن استاد از دانشجویان" icon="pi pi-user-plus" severity="secondary" outlined @click="openPromoteDialog(null)" />
|
||||||
|
</PermissionGate>
|
||||||
|
<PermissionGate permission="users:create">
|
||||||
|
<Button :label="$t('users.addUser')" icon="pi pi-plus" @click="$router.push('/users/create')" />
|
||||||
|
</PermissionGate>
|
||||||
|
</div>
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
|
<!-- Top KPI Stats Row -->
|
||||||
|
<div class="grid mb-4">
|
||||||
|
<div class="col-12 sm:col-4">
|
||||||
|
<div class="surface-card p-3 border-round-xl border-1 border-color shadow-sm flex align-items-center justify-content-between">
|
||||||
|
<div>
|
||||||
|
<span class="text-xs text-muted block mb-1">کل کاربران ثبتشده</span>
|
||||||
|
<span class="text-2xl font-bold text-color">{{ toPersianDigits(totalCount) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-3rem h-3rem border-round-xl bg-primary-50 text-primary flex align-items-center justify-content-center">
|
||||||
|
<i class="pi pi-users text-xl"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 sm:col-4">
|
||||||
|
<div class="surface-card p-3 border-round-xl border-1 border-color shadow-sm flex align-items-center justify-content-between">
|
||||||
|
<div>
|
||||||
|
<span class="text-xs text-muted block mb-1">کاربران فعال</span>
|
||||||
|
<span class="text-2xl font-bold text-green-600">{{ toPersianDigits(activeUsersCount) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-3rem h-3rem border-round-xl bg-green-50 text-green-600 flex align-items-center justify-content-center">
|
||||||
|
<i class="pi pi-check-circle text-xl"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-12 sm:col-4">
|
||||||
|
<div class="surface-card p-3 border-round-xl border-1 border-color shadow-sm flex align-items-center justify-content-between">
|
||||||
|
<div>
|
||||||
|
<span class="text-xs text-muted block mb-1">مجموع ثبتنامها در کلاس</span>
|
||||||
|
<span class="text-2xl font-bold text-blue-600">{{ toPersianDigits(totalEnrollmentsCount) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-3rem h-3rem border-round-xl bg-blue-50 text-blue-600 flex align-items-center justify-content-center">
|
||||||
|
<i class="pi pi-book text-xl"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<DataTableWrapper
|
<DataTableWrapper
|
||||||
:items="items"
|
:items="items"
|
||||||
:totalCount="totalCount"
|
:totalCount="totalCount"
|
||||||
@@ -19,23 +61,32 @@
|
|||||||
@sort-change="onSort"
|
@sort-change="onSort"
|
||||||
@search-change="onSearch"
|
@search-change="onSearch"
|
||||||
>
|
>
|
||||||
<Column field="name" header="نام" sortable>
|
<Column field="name" header="نام دانشجو / کاربر" sortable>
|
||||||
<template #body="{ data }">
|
<template #body="{ data }">
|
||||||
<router-link :to="`/users/${data._id || data.id}`" class="font-bold text-color hover:text-primary">
|
<div class="flex flex-column gap-1">
|
||||||
{{ data.name }}
|
<router-link :to="`/users/${data._id || data.id}`" class="font-bold text-color hover:text-primary text-sm">
|
||||||
</router-link>
|
{{ data.name }}
|
||||||
|
</router-link>
|
||||||
|
<span v-if="data.uniqueCode" class="text-xs text-muted" dir="ltr">{{ data.uniqueCode }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<Column field="role" header="نقش سیستم">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<Tag :value="data.role?.name || 'دانشجو'" :severity="getRoleSeverity(data.role?.name)" class="text-xs" />
|
||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
|
|
||||||
<Column field="nationalIdCode" header="کد ملی">
|
<Column field="nationalIdCode" header="کد ملی">
|
||||||
<template #body="{ data }">
|
<template #body="{ data }">
|
||||||
{{ toPersianDigits(data.nationalIdCode || data.nationalId) || '-' }}
|
<span dir="ltr">{{ toPersianDigits(data.nationalIdCode || data.nationalId) || '-' }}</span>
|
||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
|
|
||||||
<Column field="phoneNumber" header="شماره همراه">
|
<Column field="phoneNumber" header="شماره همراه">
|
||||||
<template #body="{ data }">
|
<template #body="{ data }">
|
||||||
{{ toPersianDigits(data.phoneNumber || data.phone) || '-' }}
|
<span dir="ltr">{{ toPersianDigits(data.phoneNumber || data.phone) || '-' }}</span>
|
||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
|
|
||||||
@@ -47,14 +98,15 @@
|
|||||||
:key="messenger"
|
:key="messenger"
|
||||||
:value="messenger"
|
:value="messenger"
|
||||||
severity="info"
|
severity="info"
|
||||||
|
class="text-xs"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
|
|
||||||
<Column field="registeredClassesCount" header="کلاسهای ثبتنامی">
|
<Column field="registeredClassesCount" header="کلاسهای ثبتنامی" sortable>
|
||||||
<template #body="{ data }">
|
<template #body="{ data }">
|
||||||
{{ toPersianDigits(data.registeredClassesCount ?? 0) }}
|
<Badge :value="toPersianDigits(data.registeredClassesCount ?? 0)" :severity="data.registeredClassesCount > 0 ? 'info' : 'secondary'" />
|
||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
|
|
||||||
@@ -64,11 +116,24 @@
|
|||||||
</template>
|
</template>
|
||||||
</Column>
|
</Column>
|
||||||
|
|
||||||
<Column header="عمولیات" style="width: 130px">
|
<Column header="عملیات" style="width: 150px">
|
||||||
<template #body="{ data }">
|
<template #body="{ data }">
|
||||||
<div class="flex align-items-center gap-1">
|
<div class="flex align-items-center gap-1">
|
||||||
<PermissionGate permission="users:read">
|
<PermissionGate permission="users:read">
|
||||||
<Button icon="pi pi-eye" text rounded size="small" v-tooltip.top="'مشاهده'" @click="$router.push(`/users/${data._id || data.id}`)" />
|
<Button icon="pi pi-id-card" text rounded size="small" v-tooltip.top="'مشاهده پروفایل جامع'" @click="$router.push(`/users/${data._id || data.id}`)" />
|
||||||
|
</PermissionGate>
|
||||||
|
|
||||||
|
<PermissionGate permission="professors:create">
|
||||||
|
<Button
|
||||||
|
v-if="data.role?.name !== 'Professor'"
|
||||||
|
icon="pi pi-user-plus"
|
||||||
|
text
|
||||||
|
rounded
|
||||||
|
size="small"
|
||||||
|
severity="help"
|
||||||
|
v-tooltip.top="'ارتقا به استاد'"
|
||||||
|
@click="openPromoteDialog(data._id || data.id)"
|
||||||
|
/>
|
||||||
</PermissionGate>
|
</PermissionGate>
|
||||||
|
|
||||||
<PermissionGate permission="users:update">
|
<PermissionGate permission="users:update">
|
||||||
@@ -88,11 +153,17 @@
|
|||||||
:loading="isDeleting"
|
:loading="isDeleting"
|
||||||
@confirm="handleDelete"
|
@confirm="handleDelete"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<AddProfessorFromUserDialog
|
||||||
|
v-model="promoteDialogVisible"
|
||||||
|
:preselected-user-id="selectedPromoteUserId"
|
||||||
|
@saved="loadData"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue';
|
import { ref, computed, onMounted } from 'vue';
|
||||||
import { useDataTable } from '@/composables/useDataTable';
|
import { useDataTable } from '@/composables/useDataTable';
|
||||||
import { usePersianDate } from '@/composables/usePersianDate';
|
import { usePersianDate } from '@/composables/usePersianDate';
|
||||||
import { useToast } from '@/composables/useToast';
|
import { useToast } from '@/composables/useToast';
|
||||||
@@ -100,11 +171,13 @@ import { userApi } from '@/api/userApi';
|
|||||||
import PageHeader from '@/components/common/PageHeader.vue';
|
import PageHeader from '@/components/common/PageHeader.vue';
|
||||||
import DataTableWrapper from '@/components/common/DataTableWrapper.vue';
|
import DataTableWrapper from '@/components/common/DataTableWrapper.vue';
|
||||||
import ConfirmDeleteDialog from '@/components/common/ConfirmDeleteDialog.vue';
|
import ConfirmDeleteDialog from '@/components/common/ConfirmDeleteDialog.vue';
|
||||||
|
import AddProfessorFromUserDialog from '@/components/professors/AddProfessorFromUserDialog.vue';
|
||||||
import PermissionGate from '@/components/common/PermissionGate.vue';
|
import PermissionGate from '@/components/common/PermissionGate.vue';
|
||||||
import StatusTag from '@/components/common/StatusTag.vue';
|
import StatusTag from '@/components/common/StatusTag.vue';
|
||||||
import Button from 'primevue/button';
|
import Button from 'primevue/button';
|
||||||
import Column from 'primevue/column';
|
import Column from 'primevue/column';
|
||||||
import Tag from 'primevue/tag';
|
import Tag from 'primevue/tag';
|
||||||
|
import Badge from 'primevue/badge';
|
||||||
|
|
||||||
const { toPersianDigits } = usePersianDate();
|
const { toPersianDigits } = usePersianDate();
|
||||||
const { showSuccess, showError } = useToast();
|
const { showSuccess, showError } = useToast();
|
||||||
@@ -124,6 +197,29 @@ const deleteDialogVisible = ref(false);
|
|||||||
const selectedUser = ref(null);
|
const selectedUser = ref(null);
|
||||||
const isDeleting = ref(false);
|
const isDeleting = ref(false);
|
||||||
|
|
||||||
|
const promoteDialogVisible = ref(false);
|
||||||
|
const selectedPromoteUserId = ref(null);
|
||||||
|
|
||||||
|
const activeUsersCount = computed(() => {
|
||||||
|
return items.value.filter((u) => u.isActive !== false).length;
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalEnrollmentsCount = computed(() => {
|
||||||
|
return items.value.reduce((sum, u) => sum + (u.registeredClassesCount || 0), 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
const getRoleSeverity = (roleName) => {
|
||||||
|
if (roleName === 'SuperAdmin' || roleName === 'Admin') return 'danger';
|
||||||
|
if (roleName === 'Professor') return 'help';
|
||||||
|
if (roleName === 'Secretary') return 'warning';
|
||||||
|
return 'secondary';
|
||||||
|
};
|
||||||
|
|
||||||
|
const openPromoteDialog = (userId) => {
|
||||||
|
selectedPromoteUserId.value = userId ? String(userId) : null;
|
||||||
|
promoteDialogVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
const confirmDelete = (user) => {
|
const confirmDelete = (user) => {
|
||||||
selectedUser.value = user;
|
selectedUser.value = user;
|
||||||
deleteDialogVisible.value = true;
|
deleteDialogVisible.value = true;
|
||||||
|
|||||||
@@ -0,0 +1,787 @@
|
|||||||
|
<!-- /src/views/waitlist/WaitlistListView.vue -->
|
||||||
|
<template>
|
||||||
|
<div class="waitlist-list-view">
|
||||||
|
<PageHeader title="لیست انتظار" subtitle="مدیریت دانشجویان در انتظار تشکیل کلاس و پیشثبتنامها">
|
||||||
|
<PermissionGate permission="waitlist:create">
|
||||||
|
<Button
|
||||||
|
label="افزودن به لیست انتظار"
|
||||||
|
icon="pi pi-user-plus"
|
||||||
|
severity="success"
|
||||||
|
@click="openCreateModal"
|
||||||
|
/>
|
||||||
|
</PermissionGate>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<!-- Stats Cards -->
|
||||||
|
<div class="grid mb-4" v-if="stats">
|
||||||
|
<div class="col-12 sm:col-6 lg:col-3">
|
||||||
|
<div class="surface-card p-3 border-round border-1 border-color shadow-sm flex align-items-center justify-content-between">
|
||||||
|
<div>
|
||||||
|
<span class="text-xs text-muted block mb-1">کل پیشثبتنامها</span>
|
||||||
|
<span class="text-2xl font-bold text-color">{{ toPersianDigits(stats.total || 0) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-3rem h-3rem border-round surface-100 flex align-items-center justify-content-center">
|
||||||
|
<i class="pi pi-users text-primary text-xl"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 sm:col-6 lg:col-3">
|
||||||
|
<div class="surface-card p-3 border-round border-1 border-color shadow-sm flex align-items-center justify-content-between">
|
||||||
|
<div>
|
||||||
|
<span class="text-xs text-muted block mb-1">در انتظار کلاس</span>
|
||||||
|
<span class="text-2xl font-bold text-orange-500">{{ toPersianDigits(stats.waiting || 0) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-3rem h-3rem border-round surface-100 flex align-items-center justify-content-center">
|
||||||
|
<i class="pi pi-clock text-orange-500 text-xl"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 sm:col-6 lg:col-3">
|
||||||
|
<div class="surface-card p-3 border-round border-1 border-color shadow-sm flex align-items-center justify-content-between">
|
||||||
|
<div>
|
||||||
|
<span class="text-xs text-muted block mb-1">انتقال یافته به کلاس</span>
|
||||||
|
<span class="text-2xl font-bold text-green-600">{{ toPersianDigits(stats.enrolled || 0) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-3rem h-3rem border-round surface-100 flex align-items-center justify-content-center">
|
||||||
|
<i class="pi pi-check-circle text-green-600 text-xl"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 sm:col-6 lg:col-3">
|
||||||
|
<div class="surface-card p-3 border-round border-1 border-color shadow-sm flex align-items-center justify-content-between">
|
||||||
|
<div>
|
||||||
|
<span class="text-xs text-muted block mb-1">مسترد / لغوشده</span>
|
||||||
|
<span class="text-2xl font-bold text-muted">{{ toPersianDigits((stats.reverted || 0) + (stats.cancelled || 0)) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-3rem h-3rem border-round surface-100 flex align-items-center justify-content-center">
|
||||||
|
<i class="pi pi-replay text-muted text-xl"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filter Bar -->
|
||||||
|
<div class="surface-card p-3 border-round border-1 border-color shadow-sm mb-4 flex flex-wrap gap-3 align-items-center justify-content-between">
|
||||||
|
<div class="flex flex-wrap gap-2 align-items-center flex-grow-1">
|
||||||
|
<Dropdown
|
||||||
|
v-model="selectedCourseFilter"
|
||||||
|
:options="coursesList"
|
||||||
|
optionLabel="title"
|
||||||
|
optionValue="_id"
|
||||||
|
placeholder="فیلتر بر اساس دوره…"
|
||||||
|
showClear
|
||||||
|
filter
|
||||||
|
class="text-sm"
|
||||||
|
style="min-width: 220px"
|
||||||
|
@change="onFilterChange"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Dropdown
|
||||||
|
v-model="selectedStatusFilter"
|
||||||
|
:options="statusFilterOptions"
|
||||||
|
optionLabel="label"
|
||||||
|
optionValue="value"
|
||||||
|
placeholder="فیلتر وضعیت…"
|
||||||
|
showClear
|
||||||
|
class="text-sm"
|
||||||
|
style="min-width: 170px"
|
||||||
|
@change="onFilterChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- DataTable -->
|
||||||
|
<DataTableWrapper
|
||||||
|
:items="items"
|
||||||
|
:totalCount="totalCount"
|
||||||
|
:page="queryParams.page"
|
||||||
|
:limit="queryParams.limit"
|
||||||
|
:loading="isLoading"
|
||||||
|
@page-change="onPageChange"
|
||||||
|
@sort-change="onSort"
|
||||||
|
@search-change="onSearch"
|
||||||
|
>
|
||||||
|
<Column field="uniqueCode" header="کد رهگیری" style="width: 110px">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<span class="font-bold text-xs text-color">{{ data.uniqueCode || '—' }}</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<Column field="user" header="دانشجو / متقاضی">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<div class="flex flex-column">
|
||||||
|
<span class="font-bold text-sm text-color">{{ data.user?.name || 'کاربر' }}</span>
|
||||||
|
<span v-if="data.user?.phoneNumber" class="text-xs text-muted" dir="ltr">{{ data.user.phoneNumber }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<Column field="course" header="دوره مورد تقاضا">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<span class="font-semibold text-sm text-color">{{ data.course?.title || '—' }}</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<Column field="class" header="کلاس تخصیصیافته">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<Tag v-if="data.class" :value="data.class.name" severity="success" class="text-xs" />
|
||||||
|
<Tag v-else value="هنوز کلاسی تعیین نشده" severity="secondary" class="text-xs opacity-75" />
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<Column field="payment" header="وضعیت مالی و واریزی">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<div v-if="data.payment" class="flex flex-column gap-1">
|
||||||
|
<div class="flex align-items-center gap-2">
|
||||||
|
<span class="text-xs font-bold text-color">
|
||||||
|
{{ toPersianDigits(getPayableAmount(data.payment).toLocaleString()) }} تومان
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
icon="pi pi-pencil"
|
||||||
|
text
|
||||||
|
rounded
|
||||||
|
size="small"
|
||||||
|
severity="secondary"
|
||||||
|
v-tooltip.top="'ویرایش سریع صورتحساب'"
|
||||||
|
@click="openPaymentQuickEdit(data.payment)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex align-items-center gap-1">
|
||||||
|
<span class="text-xs text-green-600 font-semibold">
|
||||||
|
واریزی: {{ toPersianDigits((data.payment.paidAmount || 0).toLocaleString()) }} تومان
|
||||||
|
</span>
|
||||||
|
<StatusTag :status="data.payment.status" type="payment" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span v-else class="text-xs text-muted">—</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<Column field="status" header="وضعیت لیست انتظار">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<Tag
|
||||||
|
v-if="data.status === 'waiting'"
|
||||||
|
value="در انتظار کلاس"
|
||||||
|
severity="warn"
|
||||||
|
class="text-xs font-semibold"
|
||||||
|
/>
|
||||||
|
<Tag
|
||||||
|
v-else-if="data.status === 'enrolled'"
|
||||||
|
value="انتقال به کلاس"
|
||||||
|
severity="success"
|
||||||
|
class="text-xs font-semibold"
|
||||||
|
/>
|
||||||
|
<Tag
|
||||||
|
v-else-if="data.status === 'reverted'"
|
||||||
|
value="مسترد شده"
|
||||||
|
severity="contrast"
|
||||||
|
class="text-xs font-semibold"
|
||||||
|
/>
|
||||||
|
<Tag
|
||||||
|
v-else-if="data.status === 'cancelled'"
|
||||||
|
value="لغوشده"
|
||||||
|
severity="secondary"
|
||||||
|
class="text-xs font-semibold"
|
||||||
|
/>
|
||||||
|
<Tag v-else :value="data.status" severity="info" class="text-xs" />
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<Column field="registeredAt" header="تاریخ ثبت">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<span class="text-xs text-muted">{{ formatJalali(data.registeredAt || data.createdAt) }}</span>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
|
||||||
|
<Column header="عملیات" style="width: 170px">
|
||||||
|
<template #body="{ data }">
|
||||||
|
<div class="flex align-items-center gap-1">
|
||||||
|
<!-- Move to Class -->
|
||||||
|
<PermissionGate permission="waitlist:update">
|
||||||
|
<Button
|
||||||
|
v-if="data.status === 'waiting'"
|
||||||
|
icon="pi pi-arrow-left"
|
||||||
|
label="انتقال به کلاس"
|
||||||
|
size="small"
|
||||||
|
severity="success"
|
||||||
|
text
|
||||||
|
v-tooltip.top="'تخصیص به کلاس فعال'"
|
||||||
|
@click="openAssignModal(data)"
|
||||||
|
/>
|
||||||
|
</PermissionGate>
|
||||||
|
|
||||||
|
<!-- Revert / Refund -->
|
||||||
|
<PermissionGate permission="waitlist:update">
|
||||||
|
<Button
|
||||||
|
v-if="data.status === 'waiting'"
|
||||||
|
icon="pi pi-replay"
|
||||||
|
text
|
||||||
|
rounded
|
||||||
|
size="small"
|
||||||
|
severity="warn"
|
||||||
|
v-tooltip.top="'استرداد وجه'"
|
||||||
|
@click="openRevertModal(data)"
|
||||||
|
/>
|
||||||
|
</PermissionGate>
|
||||||
|
|
||||||
|
<!-- Cancel -->
|
||||||
|
<PermissionGate permission="waitlist:update">
|
||||||
|
<Button
|
||||||
|
v-if="data.status === 'waiting'"
|
||||||
|
icon="pi pi-ban"
|
||||||
|
text
|
||||||
|
rounded
|
||||||
|
size="small"
|
||||||
|
severity="secondary"
|
||||||
|
v-tooltip.top="'لغو پیشثبتنام'"
|
||||||
|
@click="openCancelModal(data)"
|
||||||
|
/>
|
||||||
|
</PermissionGate>
|
||||||
|
|
||||||
|
<!-- Delete -->
|
||||||
|
<PermissionGate permission="waitlist:delete">
|
||||||
|
<Button
|
||||||
|
icon="pi pi-trash"
|
||||||
|
text
|
||||||
|
rounded
|
||||||
|
size="small"
|
||||||
|
severity="danger"
|
||||||
|
v-tooltip.top="'حذف'"
|
||||||
|
@click="confirmDelete(data)"
|
||||||
|
/>
|
||||||
|
</PermissionGate>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</Column>
|
||||||
|
</DataTableWrapper>
|
||||||
|
|
||||||
|
<!-- Create Waitlist Modal -->
|
||||||
|
<Dialog v-model:visible="showCreateModal" header="افزودن دانشجو به لیست انتظار" modal :style="{ width: '560px', maxWidth: '95vw' }">
|
||||||
|
<div class="flex flex-column gap-3 py-2">
|
||||||
|
<div class="flex flex-column gap-2">
|
||||||
|
<label class="font-semibold text-sm">انتخاب دوره آموزشی *</label>
|
||||||
|
<Dropdown
|
||||||
|
v-model="createForm.courseId"
|
||||||
|
:options="coursesList"
|
||||||
|
optionLabel="title"
|
||||||
|
optionValue="_id"
|
||||||
|
placeholder="دوره را انتخاب کنید"
|
||||||
|
filter
|
||||||
|
class="w-full text-sm"
|
||||||
|
@change="onCourseSelected"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-column gap-2">
|
||||||
|
<label class="font-semibold text-sm">انتخاب کاربر / دانشجو *</label>
|
||||||
|
<Dropdown
|
||||||
|
v-model="createForm.userId"
|
||||||
|
:options="usersList"
|
||||||
|
optionLabel="fullName"
|
||||||
|
optionValue="_id"
|
||||||
|
placeholder="دانشجو را انتخاب کنید"
|
||||||
|
filter
|
||||||
|
class="w-full text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
<div class="col-12 sm:col-6 flex flex-column gap-2">
|
||||||
|
<label class="font-semibold text-sm">شهریه دوره (تومان) *</label>
|
||||||
|
<InputGroup>
|
||||||
|
<InputNumber v-model="createForm.amount" class="w-full text-sm" :min="0" />
|
||||||
|
<InputGroupAddon>تومان</InputGroupAddon>
|
||||||
|
</InputGroup>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 sm:col-6 flex flex-column gap-2">
|
||||||
|
<label class="font-semibold text-sm">تخفیف (تومان)</label>
|
||||||
|
<InputGroup>
|
||||||
|
<InputNumber v-model="createForm.discount" class="w-full text-sm" :min="0" :max="createForm.amount || 0" />
|
||||||
|
<InputGroupAddon>تومان</InputGroupAddon>
|
||||||
|
</InputGroup>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex align-items-center gap-2 mt-1">
|
||||||
|
<Checkbox v-model="createForm.hasInitialPayment" binary inputId="wl-has-deposit" />
|
||||||
|
<label for="wl-has-deposit" class="font-semibold text-sm cursor-pointer">
|
||||||
|
پیشپرداخت / بیعانه هماکنون واریز شده است
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Initial Transaction fields -->
|
||||||
|
<div v-if="createForm.hasInitialPayment" class="p-3 border-1 border-color border-round flex flex-column gap-3 surface-50">
|
||||||
|
<h5 class="text-sm font-bold text-color m-0">مشخصات واریز بیعانه</h5>
|
||||||
|
<div class="grid">
|
||||||
|
<div class="col-12 sm:col-6 flex flex-column gap-2">
|
||||||
|
<label class="font-semibold text-xs">مبلغ پرداختی (تومان) *</label>
|
||||||
|
<InputGroup>
|
||||||
|
<InputNumber v-model="createForm.depositAmount" class="w-full text-sm" :min="1" />
|
||||||
|
<InputGroupAddon>تومان</InputGroupAddon>
|
||||||
|
</InputGroup>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 sm:col-6 flex flex-column gap-2">
|
||||||
|
<label class="font-semibold text-xs">روش پرداخت</label>
|
||||||
|
<Dropdown v-model="createForm.depositMethod" :options="depositMethodOptions" optionLabel="label" optionValue="value" class="w-full text-sm" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 sm:col-6 flex flex-column gap-2">
|
||||||
|
<label class="font-semibold text-xs">تاریخ پرداخت</label>
|
||||||
|
<DatePicker v-model="createForm.depositDate" class="w-full text-sm" :placeholder="getTodayJalali()" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 sm:col-6 flex flex-column gap-2">
|
||||||
|
<label class="font-semibold text-xs">شماره فیش / پیگیری</label>
|
||||||
|
<InputText v-model.trim="createForm.depositReceipt" class="w-full text-sm" dir="ltr" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-column gap-2">
|
||||||
|
<label class="font-semibold text-sm">یادداشت داخلی</label>
|
||||||
|
<Textarea v-model="createForm.notes" rows="2" class="w-full text-sm" placeholder="توضیحات مربوط به متقاضی…" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<Button label="انصراف" text severity="secondary" @click="showCreateModal = false" />
|
||||||
|
<Button label="ثبت در لیست انتظار" icon="pi pi-check" severity="success" :loading="isCreating" @click="handleCreateWaitlist" />
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<!-- Move / Assign to Class Modal -->
|
||||||
|
<Dialog v-model:visible="showAssignModal" header="انتقال دانشجو به کلاس" modal :style="{ width: '500px' }">
|
||||||
|
<div class="flex flex-column gap-3 py-2" v-if="selectedItem">
|
||||||
|
<div class="p-3 border-round surface-100 flex flex-column gap-1">
|
||||||
|
<span class="text-xs text-muted">دانشجو: <strong class="text-color">{{ selectedItem.user?.name }}</strong></span>
|
||||||
|
<span class="text-xs text-muted">دوره: <strong class="text-color">{{ selectedItem.course?.title }}</strong></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-column gap-2">
|
||||||
|
<label class="font-semibold text-sm">انتخاب کلاس مورد نظر برای انتقال *</label>
|
||||||
|
<Dropdown
|
||||||
|
v-model="assignClassId"
|
||||||
|
:options="courseClasses"
|
||||||
|
optionLabel="displayName"
|
||||||
|
optionValue="_id"
|
||||||
|
placeholder="کلاس را انتخاب کنید"
|
||||||
|
class="w-full text-sm"
|
||||||
|
filter
|
||||||
|
/>
|
||||||
|
<small v-if="!courseClasses.length" class="text-orange-500 text-xs">
|
||||||
|
هیچ کلاس فعالی برای این دوره یافت نشد. ابتدا باید یک کلاس ایجاد کنید.
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-2 border-round surface-50 border-1 border-color text-xs text-muted line-height-3">
|
||||||
|
<i class="pi pi-info-circle text-primary ml-1"></i>
|
||||||
|
با انتقال دانشجو به کلاس، وضعیت صورتحساب از «لیست انتظار» به حالت عادی تغییر کرده و پرداختیها به عنوان درآمد واقعی کلاس و در سهم استاد محاسبه خواهند شد.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<Button label="انصراف" text severity="secondary" @click="showAssignModal = false" />
|
||||||
|
<Button
|
||||||
|
label="تأیید و انتقال به کلاس"
|
||||||
|
icon="pi pi-check"
|
||||||
|
severity="success"
|
||||||
|
:loading="isAssigning"
|
||||||
|
:disabled="!assignClassId"
|
||||||
|
@click="submitAssignClass"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<!-- Revert / Refund Confirmation Dialog -->
|
||||||
|
<Dialog v-model:visible="showRevertModal" header="استرداد وجه و لغو پیشثبتنام" modal :style="{ width: '450px' }">
|
||||||
|
<div class="flex flex-column gap-3 py-2" v-if="selectedItem">
|
||||||
|
<div class="flex align-items-center gap-2 text-yellow-600">
|
||||||
|
<i class="pi pi-exclamation-triangle text-2xl"></i>
|
||||||
|
<span class="font-bold text-sm">آیا از استرداد وجه متقاضی «{{ selectedItem.user?.name }}» اطمینان دارید؟</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-muted m-0 line-height-3">
|
||||||
|
با استرداد، وضعیت این پیشثبتنام و تمام صورتحسابها و تراکنشهای مربوطه به وضعیت «مسترد شده» تغییر خواهند یافت.
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-column gap-2">
|
||||||
|
<label class="font-semibold text-xs">دلیل یا توضیحات استرداد</label>
|
||||||
|
<Textarea v-model="revertNotes" rows="2" class="w-full text-sm" placeholder="مثلا: عدم تشکیل کلاس در موعد مقرر…" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<Button label="انصراف" text severity="secondary" @click="showRevertModal = false" />
|
||||||
|
<Button label="استرداد وجه و ثبت" icon="pi pi-replay" severity="warn" :loading="isReverting" @click="submitRevert" />
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<!-- Cancel Confirmation Dialog -->
|
||||||
|
<Dialog v-model:visible="showCancelModal" header="لغو پیشثبتنام" modal :style="{ width: '450px' }">
|
||||||
|
<div class="flex flex-column gap-3 py-2" v-if="selectedItem">
|
||||||
|
<div class="flex align-items-center gap-2 text-red-500">
|
||||||
|
<i class="pi pi-ban text-2xl"></i>
|
||||||
|
<span class="font-bold text-sm">آیا از لغو پیشثبتنام «{{ selectedItem.user?.name }}» اطمینان دارید؟</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-column gap-2">
|
||||||
|
<label class="font-semibold text-xs">دلیل لغو</label>
|
||||||
|
<Textarea v-model="cancelNotes" rows="2" class="w-full text-sm" placeholder="توضیحات لغو…" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<template #footer>
|
||||||
|
<Button label="انصراف" text severity="secondary" @click="showCancelModal = false" />
|
||||||
|
<Button label="لغو پیشثبتنام" icon="pi pi-check" severity="danger" :loading="isCancelling" @click="submitCancel" />
|
||||||
|
</template>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
<!-- Delete Confirmation -->
|
||||||
|
<ConfirmDeleteDialog
|
||||||
|
v-model="deleteDialogVisible"
|
||||||
|
:loading="isDeleting"
|
||||||
|
@confirm="handleDelete"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Quick Edit Payment Modal -->
|
||||||
|
<QuickEditPaymentDialog
|
||||||
|
v-model:visible="quickEditVisible"
|
||||||
|
:payment-id="quickEditPaymentId"
|
||||||
|
@updated="refreshAll"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, reactive, onMounted } from 'vue';
|
||||||
|
import { waitlistApi } from '@/api/waitlistApi';
|
||||||
|
import { courseApi } from '@/api/courseApi';
|
||||||
|
import { userApi } from '@/api/userApi';
|
||||||
|
import { classApi } from '@/api/classApi';
|
||||||
|
import { useDataTable } from '@/composables/useDataTable';
|
||||||
|
import { usePersianDate } from '@/composables/usePersianDate';
|
||||||
|
import { useToast } from '@/composables/useToast';
|
||||||
|
import { getPayableAmount } from '@/utils/paymentAmount';
|
||||||
|
import PageHeader from '@/components/common/PageHeader.vue';
|
||||||
|
import DataTableWrapper from '@/components/common/DataTableWrapper.vue';
|
||||||
|
import ConfirmDeleteDialog from '@/components/common/ConfirmDeleteDialog.vue';
|
||||||
|
import PermissionGate from '@/components/common/PermissionGate.vue';
|
||||||
|
import StatusTag from '@/components/common/StatusTag.vue';
|
||||||
|
import QuickEditPaymentDialog from '@/components/payments/QuickEditPaymentDialog.vue';
|
||||||
|
import Button from 'primevue/button';
|
||||||
|
import Column from 'primevue/column';
|
||||||
|
import Tag from 'primevue/tag';
|
||||||
|
import Dialog from 'primevue/dialog';
|
||||||
|
import Dropdown from 'primevue/select';
|
||||||
|
import InputText from 'primevue/inputtext';
|
||||||
|
import InputNumber from 'primevue/inputnumber';
|
||||||
|
import InputGroup from 'primevue/inputgroup';
|
||||||
|
import InputGroupAddon from 'primevue/inputgroupaddon';
|
||||||
|
import Checkbox from 'primevue/checkbox';
|
||||||
|
import Textarea from 'primevue/textarea';
|
||||||
|
import DatePicker from 'vue3-persian-datetime-picker';
|
||||||
|
|
||||||
|
const { toPersianDigits, formatJalali, toGregorianIso, getTodayJalali } = usePersianDate();
|
||||||
|
const { showSuccess, showError } = useToast();
|
||||||
|
|
||||||
|
const {
|
||||||
|
items,
|
||||||
|
totalCount,
|
||||||
|
isLoading,
|
||||||
|
queryParams,
|
||||||
|
loadData,
|
||||||
|
onPageChange,
|
||||||
|
onSort,
|
||||||
|
onSearch
|
||||||
|
} = useDataTable(waitlistApi.getAll);
|
||||||
|
|
||||||
|
const stats = ref(null);
|
||||||
|
const coursesList = ref([]);
|
||||||
|
const usersList = ref([]);
|
||||||
|
const selectedCourseFilter = ref(null);
|
||||||
|
const selectedStatusFilter = ref(null);
|
||||||
|
|
||||||
|
const statusFilterOptions = [
|
||||||
|
{ label: 'همه وضعیتها', value: null },
|
||||||
|
{ label: 'در انتظار کلاس', value: 'waiting' },
|
||||||
|
{ label: 'انتقال به کلاس', value: 'enrolled' },
|
||||||
|
{ label: 'مسترد شده', value: 'reverted' },
|
||||||
|
{ label: 'لغوشده', value: 'cancelled' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const depositMethodOptions = [
|
||||||
|
{ label: 'کارت به کارت', value: 'card' },
|
||||||
|
{ label: 'درگاه آنلاین', value: 'online' },
|
||||||
|
{ label: 'نقدی', value: 'cash' }
|
||||||
|
];
|
||||||
|
|
||||||
|
// Quick Edit Modal
|
||||||
|
const quickEditVisible = ref(false);
|
||||||
|
const quickEditPaymentId = ref('');
|
||||||
|
const openPaymentQuickEdit = (pmt) => {
|
||||||
|
const id = typeof pmt === 'object' ? (pmt._id || pmt.id) : pmt;
|
||||||
|
quickEditPaymentId.value = String(id);
|
||||||
|
quickEditVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create Modal
|
||||||
|
const showCreateModal = ref(false);
|
||||||
|
const isCreating = ref(false);
|
||||||
|
const createForm = reactive({
|
||||||
|
courseId: null,
|
||||||
|
userId: null,
|
||||||
|
amount: 0,
|
||||||
|
discount: 0,
|
||||||
|
hasInitialPayment: true,
|
||||||
|
depositAmount: 0,
|
||||||
|
depositMethod: 'card',
|
||||||
|
depositDate: getTodayJalali(),
|
||||||
|
depositReceipt: '',
|
||||||
|
notes: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
// Assign to class modal
|
||||||
|
const showAssignModal = ref(false);
|
||||||
|
const isAssigning = ref(false);
|
||||||
|
const selectedItem = ref(null);
|
||||||
|
const assignClassId = ref(null);
|
||||||
|
const courseClasses = ref([]);
|
||||||
|
|
||||||
|
// Revert Modal
|
||||||
|
const showRevertModal = ref(false);
|
||||||
|
const isReverting = ref(false);
|
||||||
|
const revertNotes = ref('');
|
||||||
|
|
||||||
|
// Cancel Modal
|
||||||
|
const showCancelModal = ref(false);
|
||||||
|
const isCancelling = ref(false);
|
||||||
|
const cancelNotes = ref('');
|
||||||
|
|
||||||
|
// Delete Modal
|
||||||
|
const deleteDialogVisible = ref(false);
|
||||||
|
const isDeleting = ref(false);
|
||||||
|
const itemToDelete = ref(null);
|
||||||
|
|
||||||
|
const fetchStats = async () => {
|
||||||
|
try {
|
||||||
|
const res = await waitlistApi.getStats();
|
||||||
|
stats.value = res.data || res;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to fetch waitlist stats:', e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchDropdowns = async () => {
|
||||||
|
try {
|
||||||
|
const [cRes, uRes] = await Promise.allSettled([
|
||||||
|
courseApi.getAll({ limit: 100 }),
|
||||||
|
userApi.getAll({ limit: 200 })
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (cRes.status === 'fulfilled') {
|
||||||
|
const d = cRes.value.data || cRes.value;
|
||||||
|
coursesList.value = Array.isArray(d) ? d : (d.items || d.courses || d.data || []);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uRes.status === 'fulfilled') {
|
||||||
|
const d = uRes.value.data || uRes.value;
|
||||||
|
const list = Array.isArray(d) ? d : (d.items || d.users || d.data || []);
|
||||||
|
usersList.value = list.map((u) => ({
|
||||||
|
...u,
|
||||||
|
fullName: `${u.name || ''} — ${u.phoneNumber || ''}`.trim()
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Dropdown prefetch error:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onFilterChange = () => {
|
||||||
|
queryParams.courseId = selectedCourseFilter.value || undefined;
|
||||||
|
queryParams.status = selectedStatusFilter.value || undefined;
|
||||||
|
queryParams.page = 1;
|
||||||
|
loadData();
|
||||||
|
};
|
||||||
|
|
||||||
|
const openCreateModal = () => {
|
||||||
|
Object.assign(createForm, {
|
||||||
|
courseId: null,
|
||||||
|
userId: null,
|
||||||
|
amount: 0,
|
||||||
|
discount: 0,
|
||||||
|
hasInitialPayment: true,
|
||||||
|
depositAmount: 0,
|
||||||
|
depositMethod: 'card',
|
||||||
|
depositDate: getTodayJalali(),
|
||||||
|
depositReceipt: '',
|
||||||
|
notes: ''
|
||||||
|
});
|
||||||
|
showCreateModal.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onCourseSelected = () => {
|
||||||
|
const selected = coursesList.value.find((c) => String(c._id) === String(createForm.courseId));
|
||||||
|
if (selected) {
|
||||||
|
createForm.amount = selected.price || 0;
|
||||||
|
createForm.depositAmount = selected.price || 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateWaitlist = async () => {
|
||||||
|
if (!createForm.courseId) {
|
||||||
|
showError('انتخاب دوره الزامی است');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!createForm.userId) {
|
||||||
|
showError('انتخاب دانشجو الزامی است');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
isCreating.value = true;
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
courseId: createForm.courseId,
|
||||||
|
userId: createForm.userId,
|
||||||
|
amount: createForm.amount,
|
||||||
|
discount: createForm.discount,
|
||||||
|
notes: createForm.notes
|
||||||
|
};
|
||||||
|
|
||||||
|
if (createForm.hasInitialPayment && createForm.depositAmount > 0) {
|
||||||
|
payload.initialTransaction = {
|
||||||
|
amount: createForm.depositAmount,
|
||||||
|
method: createForm.depositMethod,
|
||||||
|
date: toGregorianIso(createForm.depositDate),
|
||||||
|
receiptNumber: createForm.depositReceipt,
|
||||||
|
notes: createForm.notes || 'پرداخت بیعانه ثبتنام لیست انتظار',
|
||||||
|
status: 'paid'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await waitlistApi.create(payload);
|
||||||
|
showSuccess('دانشجو با موفقیت در لیست انتظار ثبت شد');
|
||||||
|
showCreateModal.value = false;
|
||||||
|
refreshAll();
|
||||||
|
} catch (err) {
|
||||||
|
showError(err);
|
||||||
|
} finally {
|
||||||
|
isCreating.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openAssignModal = async (item) => {
|
||||||
|
selectedItem.value = item;
|
||||||
|
assignClassId.value = null;
|
||||||
|
courseClasses.value = [];
|
||||||
|
showAssignModal.value = true;
|
||||||
|
|
||||||
|
const courseId = item.course?._id || item.course;
|
||||||
|
if (courseId) {
|
||||||
|
try {
|
||||||
|
const res = await classApi.getAll({ courseId, limit: 50 });
|
||||||
|
const data = res.data || res;
|
||||||
|
const list = Array.isArray(data) ? data : (data.items || data.classes || data.data || []);
|
||||||
|
courseClasses.value = list.map((c) => ({
|
||||||
|
...c,
|
||||||
|
displayName: `${c.name} (ظرفیت: ${c.capacity || 0} نفر — شهریه: ${toPersianDigits((c.tuitionFee || 0).toLocaleString())} تومان)`
|
||||||
|
}));
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to fetch course classes:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitAssignClass = async () => {
|
||||||
|
if (!selectedItem.value?._id || !assignClassId.value) return;
|
||||||
|
isAssigning.value = true;
|
||||||
|
try {
|
||||||
|
await waitlistApi.assignClass(selectedItem.value._id, { classId: assignClassId.value });
|
||||||
|
showSuccess('دانشجو با موفقیت به کلاس منتقل شد و صورتحساب به کلاس متصل گردید');
|
||||||
|
showAssignModal.value = false;
|
||||||
|
selectedItem.value = null;
|
||||||
|
refreshAll();
|
||||||
|
} catch (err) {
|
||||||
|
showError(err);
|
||||||
|
} finally {
|
||||||
|
isAssigning.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openRevertModal = (item) => {
|
||||||
|
selectedItem.value = item;
|
||||||
|
revertNotes.value = '';
|
||||||
|
showRevertModal.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitRevert = async () => {
|
||||||
|
if (!selectedItem.value?._id) return;
|
||||||
|
isReverting.value = true;
|
||||||
|
try {
|
||||||
|
await waitlistApi.revert(selectedItem.value._id, { notes: revertNotes.value });
|
||||||
|
showSuccess('پیشثبتنام با موفقیت مسترد شد و وضعیت تراکنشها بهروز گردید');
|
||||||
|
showRevertModal.value = false;
|
||||||
|
selectedItem.value = null;
|
||||||
|
refreshAll();
|
||||||
|
} catch (err) {
|
||||||
|
showError(err);
|
||||||
|
} finally {
|
||||||
|
isReverting.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openCancelModal = (item) => {
|
||||||
|
selectedItem.value = item;
|
||||||
|
cancelNotes.value = '';
|
||||||
|
showCancelModal.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const submitCancel = async () => {
|
||||||
|
if (!selectedItem.value?._id) return;
|
||||||
|
isCancelling.value = true;
|
||||||
|
try {
|
||||||
|
await waitlistApi.cancel(selectedItem.value._id, { notes: cancelNotes.value });
|
||||||
|
showSuccess('پیشثبتنام با موفقیت لغو شد');
|
||||||
|
showCancelModal.value = false;
|
||||||
|
selectedItem.value = null;
|
||||||
|
refreshAll();
|
||||||
|
} catch (err) {
|
||||||
|
showError(err);
|
||||||
|
} finally {
|
||||||
|
isCancelling.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const confirmDelete = (item) => {
|
||||||
|
itemToDelete.value = item;
|
||||||
|
deleteDialogVisible.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!itemToDelete.value?._id) return;
|
||||||
|
isDeleting.value = true;
|
||||||
|
try {
|
||||||
|
await waitlistApi.delete(itemToDelete.value._id);
|
||||||
|
showSuccess('مورد از لیست انتظار حذف شد');
|
||||||
|
deleteDialogVisible.value = false;
|
||||||
|
itemToDelete.value = null;
|
||||||
|
refreshAll();
|
||||||
|
} catch (err) {
|
||||||
|
showError(err);
|
||||||
|
} finally {
|
||||||
|
isDeleting.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const refreshAll = () => {
|
||||||
|
loadData();
|
||||||
|
fetchStats();
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchDropdowns();
|
||||||
|
fetchStats();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.waitlist-list-view {
|
||||||
|
direction: rtl;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user