Initial commit: fabric-trade-admin panel with responsive layouts and UI refinements.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+10
@@ -0,0 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import ToastContainer from '@/components/ui/ToastContainer.vue'
|
||||
import ConfirmDialog from '@/components/ui/ConfirmDialog.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterView />
|
||||
<ToastContainer />
|
||||
<ConfirmDialog />
|
||||
</template>
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,629 @@
|
||||
.admin-card {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.admin-page-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.admin-page-header__title {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.admin-page-header__breadcrumb {
|
||||
margin: 4px 0 0;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.admin-table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.admin-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.admin-table th {
|
||||
padding: 11px 16px;
|
||||
text-align: right;
|
||||
font-weight: 600;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-muted);
|
||||
background: #f8fafc;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-table td {
|
||||
padding: 13px 16px;
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.admin-table tbody tr {
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
.admin-table tbody tr:hover {
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.admin-table tbody tr.is-selected {
|
||||
background: var(--color-primary-soft);
|
||||
}
|
||||
|
||||
.admin-table .col-numeric,
|
||||
.admin-table td.numeric,
|
||||
.admin-table th.numeric {
|
||||
text-align: left;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.admin-table .col-status,
|
||||
.admin-table td.status,
|
||||
.admin-table th.status {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.admin-table .col-actions,
|
||||
.admin-table td.actions,
|
||||
.admin-table th.actions {
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-table tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.admin-table--compact td,
|
||||
.admin-table--compact th {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.admin-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
min-height: var(--control-height);
|
||||
padding: 0 20px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
border: 1px solid transparent;
|
||||
transition: background var(--transition-fast), border-color var(--transition-fast), color var(--transition-fast), box-shadow var(--transition-fast);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-btn:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
|
||||
.admin-btn:active:not(:disabled) {
|
||||
transform: translateY(1px);
|
||||
}
|
||||
|
||||
.admin-btn--primary {
|
||||
background: var(--btn-primary-bg);
|
||||
color: #fff;
|
||||
border-color: var(--btn-primary-bg);
|
||||
}
|
||||
|
||||
.admin-btn--primary:hover:not(:disabled) {
|
||||
background: var(--btn-primary-hover);
|
||||
}
|
||||
|
||||
.admin-btn--danger {
|
||||
background: var(--color-danger);
|
||||
color: #fff;
|
||||
border-color: var(--color-danger);
|
||||
}
|
||||
|
||||
.admin-btn--danger:hover:not(:disabled) {
|
||||
background: #c82333;
|
||||
}
|
||||
|
||||
.admin-btn--success {
|
||||
background: var(--color-success);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.admin-btn--success:hover {
|
||||
background: var(--color-success-dark);
|
||||
}
|
||||
|
||||
.admin-btn--outline {
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
|
||||
.admin-btn--outline:hover {
|
||||
background: var(--color-border-light);
|
||||
}
|
||||
|
||||
.admin-btn--outline-success {
|
||||
background: var(--color-surface);
|
||||
color: var(--color-success);
|
||||
border-color: var(--color-success);
|
||||
}
|
||||
|
||||
.admin-btn--outline-danger {
|
||||
background: var(--color-surface);
|
||||
color: var(--color-danger);
|
||||
border-color: var(--color-danger);
|
||||
}
|
||||
|
||||
.admin-btn--outline-warning {
|
||||
background: var(--color-surface);
|
||||
color: var(--color-warning-dark);
|
||||
border-color: var(--color-warning);
|
||||
}
|
||||
|
||||
.admin-btn--ghost {
|
||||
background: transparent;
|
||||
color: var(--color-primary);
|
||||
border-color: transparent;
|
||||
padding: 6px 12px;
|
||||
}
|
||||
|
||||
.admin-btn--ghost:hover {
|
||||
background: var(--color-info-light);
|
||||
}
|
||||
|
||||
.admin-btn--sm {
|
||||
min-height: var(--control-height-sm);
|
||||
padding: 0 14px;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.admin-btn--xs {
|
||||
min-height: 28px;
|
||||
padding: 0 10px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.admin-btn__spinner {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 2px solid rgba(255, 255, 255, 0.35);
|
||||
border-top-color: #fff;
|
||||
border-radius: 50%;
|
||||
animation: admin-spin 0.7s linear infinite;
|
||||
}
|
||||
|
||||
.admin-btn--outline .admin-btn__spinner {
|
||||
border-color: rgba(23, 55, 159, 0.2);
|
||||
border-top-color: var(--color-primary);
|
||||
}
|
||||
|
||||
@keyframes admin-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.admin-btn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.admin-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 24px;
|
||||
padding: 2px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.admin-badge--warning {
|
||||
background: var(--color-warning-light);
|
||||
color: var(--color-warning-dark);
|
||||
}
|
||||
|
||||
.admin-badge--purple {
|
||||
background: var(--color-purple-light);
|
||||
color: var(--color-purple);
|
||||
}
|
||||
|
||||
.admin-badge--success {
|
||||
background: var(--color-success-light);
|
||||
color: var(--color-success-dark);
|
||||
}
|
||||
|
||||
.admin-badge--danger {
|
||||
background: var(--color-danger-light);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.admin-badge--info {
|
||||
background: var(--color-info-light);
|
||||
color: var(--color-info);
|
||||
}
|
||||
|
||||
.admin-badge--neutral {
|
||||
background: #f1f5f9;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.admin-badge--delivered {
|
||||
background: #ecfdf5;
|
||||
color: #059669;
|
||||
}
|
||||
|
||||
.admin-tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.admin-tab {
|
||||
padding: 12px 16px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-muted);
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
transition: color var(--transition-fast), border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.admin-tab:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: inset 0 -2px 0 var(--color-primary);
|
||||
}
|
||||
|
||||
.admin-tab:hover {
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.admin-tab.active {
|
||||
color: var(--color-primary);
|
||||
border-bottom-color: var(--color-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.admin-input {
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.875rem;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.admin-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
|
||||
.admin-input--sm {
|
||||
padding: 6px 10px;
|
||||
font-size: 0.8125rem;
|
||||
max-width: 120px;
|
||||
}
|
||||
|
||||
.admin-stat-card {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.admin-stat-card__label {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.admin-stat-card__value {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.admin-stat-card__icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: var(--radius-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.admin-stat-card__link {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-primary);
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.admin-link {
|
||||
color: var(--color-primary);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.admin-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.admin-pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px;
|
||||
border-top: 1px solid var(--color-border-light);
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.admin-pagination__pages {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.admin-pagination__page {
|
||||
min-width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.admin-pagination__page.active {
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.admin-summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.admin-summary-item {
|
||||
background: var(--color-border-light);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.admin-summary-item__label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.admin-summary-item__value {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.admin-info-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.admin-info-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.admin-info-row__label {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.admin-info-row__value {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.admin-logo-par {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: #17379f;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 900;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.admin-logo-fam {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: #f5f0e8;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.5rem;
|
||||
font-weight: 700;
|
||||
color: #17379f;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.admin-logo-fam span:first-child {
|
||||
font-size: 0.625rem;
|
||||
}
|
||||
|
||||
.admin-action-group {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.admin-section-card {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 20px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.admin-section-card__title {
|
||||
margin: 0 0 16px;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.admin-form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 16px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.admin-form-field label {
|
||||
display: block;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.admin-form-field textarea {
|
||||
width: 100%;
|
||||
min-height: 80px;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.875rem;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.admin-footer-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 24px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.admin-empty-placeholder {
|
||||
padding: 48px 24px;
|
||||
text-align: center;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.admin-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.admin-form__error {
|
||||
margin: 0;
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-danger-light);
|
||||
color: var(--color-danger);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.admin-form__field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.admin-form__field label {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.admin-form__required {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.admin-form__textarea {
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.875rem;
|
||||
resize: vertical;
|
||||
min-height: 80px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.admin-form__textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: var(--focus-ring);
|
||||
}
|
||||
|
||||
.admin-skeleton {
|
||||
background: linear-gradient(90deg, #f1f5f9 25%, #e2e8f0 50%, #f1f5f9 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: admin-skeleton 1.2s ease-in-out infinite;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
@keyframes admin-skeleton {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.admin-skeleton {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.admin-btn__spinner {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.admin-form__row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
@font-face {
|
||||
font-family: 'IRANSans';
|
||||
src: url('../fonts/iranSans/woff2/IRANSansWeb(FaNum).woff2') format('woff2');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'IRANSans';
|
||||
src: url('../fonts/iranSans/woff2/IRANSansWeb(FaNum)_Light.woff2') format('woff2');
|
||||
font-weight: 300;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'IRANSans';
|
||||
src: url('../fonts/iranSans/woff2/IRANSansWeb(FaNum)_Medium.woff2') format('woff2');
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'IRANSans';
|
||||
src: url('../fonts/iranSans/woff2/IRANSansWeb(FaNum)_Bold.woff2') format('woff2');
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'IRANSans';
|
||||
src: url('../fonts/iranSans/woff2/IRANSansWeb(FaNum)_Black.woff2') format('woff2');
|
||||
font-weight: 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
@import './fonts.css';
|
||||
|
||||
:root {
|
||||
--color-primary: #17379f;
|
||||
--color-primary-hover: #102b83;
|
||||
--color-primary-soft: #eef3ff;
|
||||
--color-sidebar-bg: #1a2542;
|
||||
--color-sidebar-active: #243456;
|
||||
--color-sidebar-hover: #243456;
|
||||
--color-success: #159660;
|
||||
--color-success-light: #eaf8f1;
|
||||
--color-success-dark: #0d7a4d;
|
||||
--color-warning: #f59e0b;
|
||||
--color-warning-light: #fff6e5;
|
||||
--color-warning-dark: #d97706;
|
||||
--color-danger: #dc3545;
|
||||
--color-danger-light: #fee2e2;
|
||||
--color-purple: #7c3aed;
|
||||
--color-purple-light: #ede9fe;
|
||||
--color-info: #17379f;
|
||||
--color-info-light: #eef3ff;
|
||||
--color-bg: #f0f2f7;
|
||||
--color-surface: #ffffff;
|
||||
--color-border: #e5e8ef;
|
||||
--color-border-light: #f1f3f7;
|
||||
--color-text: #182033;
|
||||
--color-text-muted: #667085;
|
||||
--color-text-secondary: #94a3b8;
|
||||
--radius-sm: 8px;
|
||||
--radius-md: 12px;
|
||||
--radius-lg: 16px;
|
||||
--shadow-sm: 0 1px 2px rgba(16, 24, 40, 0.04);
|
||||
--shadow-card: 0 1px 3px rgba(16, 24, 40, 0.06), 0 4px 16px rgba(16, 24, 40, 0.04);
|
||||
--sidebar-width: 240px;
|
||||
--header-height: 72px;
|
||||
--font-family: 'IRANSans', 'Tahoma', sans-serif;
|
||||
--btn-primary-bg: #1e293b;
|
||||
--btn-primary-hover: #0f172a;
|
||||
--admin-desktop-min: 1280px;
|
||||
--control-height: 40px;
|
||||
--control-height-sm: 32px;
|
||||
--space-1: 4px;
|
||||
--space-2: 8px;
|
||||
--space-3: 12px;
|
||||
--space-4: 16px;
|
||||
--space-5: 20px;
|
||||
--space-6: 24px;
|
||||
--transition-fast: 0.15s ease;
|
||||
--transition-base: 0.18s ease;
|
||||
--focus-ring: 0 0 0 3px rgba(23, 55, 159, 0.14);
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
font-size: 16px;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: var(--font-family);
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
line-height: 1.5;
|
||||
direction: rtl;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#app {
|
||||
height: 100dvh;
|
||||
}
|
||||
|
||||
/* Hide scrollbars across the admin panel (scroll still works) */
|
||||
html,
|
||||
body,
|
||||
#app,
|
||||
#app *,
|
||||
.admin-modal-backdrop,
|
||||
.admin-modal-backdrop * {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
html::-webkit-scrollbar,
|
||||
body::-webkit-scrollbar,
|
||||
#app::-webkit-scrollbar,
|
||||
#app *::-webkit-scrollbar,
|
||||
.admin-modal-backdrop::-webkit-scrollbar,
|
||||
.admin-modal-backdrop *::-webkit-scrollbar {
|
||||
display: none;
|
||||
width: 0;
|
||||
height: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
button {
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
/* Admin responsive breakpoints:
|
||||
xs (small mobile): below 480px
|
||||
sm (mobile): 480px – 767px
|
||||
md (tablet): 768px – 1023px
|
||||
lg (small desktop): 1024px – 1279px
|
||||
xl (desktop): 1280px – 1439px
|
||||
2xl (large desktop): 1440px+
|
||||
*/
|
||||
|
||||
:root {
|
||||
--page-padding-mobile: 16px;
|
||||
--page-padding-tablet: 20px;
|
||||
--page-padding-desktop: 28px;
|
||||
--page-padding: var(--page-padding-mobile);
|
||||
--mobile-nav-height: 0px;
|
||||
--drawer-width: min(300px, 88vw);
|
||||
--admin-desktop-min: 1280px;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
:root {
|
||||
--page-padding: var(--page-padding-tablet);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
:root {
|
||||
--page-padding: var(--page-padding-desktop);
|
||||
}
|
||||
}
|
||||
|
||||
/* Visibility utilities — desktop layout is source of truth at >= 1280px */
|
||||
.desktop-only {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.compact-only {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
.desktop-only {
|
||||
display: revert !important;
|
||||
}
|
||||
|
||||
.desktop-only.admin-shell__sidebar,
|
||||
.desktop-only.admin-sidebar {
|
||||
display: flex !important;
|
||||
}
|
||||
|
||||
.desktop-only.orders-page,
|
||||
.desktop-only.dashboard {
|
||||
display: block !important;
|
||||
}
|
||||
|
||||
.compact-only {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile drawer overlay */
|
||||
.admin-drawer-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 900;
|
||||
background: rgba(15, 23, 42, 0.45);
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 0.2s, visibility 0.2s;
|
||||
}
|
||||
|
||||
.admin-drawer-backdrop.open {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.admin-drawer {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
z-index: 901;
|
||||
width: var(--drawer-width);
|
||||
height: 100dvh;
|
||||
background: var(--color-sidebar-bg);
|
||||
color: #fff;
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.25s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.admin-drawer.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
/* Compact card list pattern */
|
||||
.admin-card-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.admin-card-item {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 14px 16px;
|
||||
text-align: right;
|
||||
width: 100%;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.admin-card-item:hover {
|
||||
border-color: #cbd5e1;
|
||||
}
|
||||
|
||||
.admin-card-item.selected {
|
||||
border-color: var(--color-primary);
|
||||
background: var(--color-info-light);
|
||||
}
|
||||
|
||||
.admin-card-item__header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.admin-card-item__title {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.admin-card-item__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 16px;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.admin-card-item__meta span strong {
|
||||
color: var(--color-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.admin-card-item__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
/* Full-screen detail panel (mobile) */
|
||||
.admin-detail-panel {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 850;
|
||||
background: var(--color-bg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.25s ease;
|
||||
}
|
||||
|
||||
.admin-detail-panel.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.admin-detail-panel__header {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px var(--page-padding);
|
||||
background: var(--color-surface);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.admin-detail-panel__back {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-text);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.admin-detail-panel__title {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.admin-detail-panel__body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--page-padding);
|
||||
}
|
||||
|
||||
/* Compact page header */
|
||||
.compact-page-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.compact-page-header__row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.compact-page-header__title {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.compact-page-header__breadcrumb {
|
||||
margin: 4px 0 0;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.compact-page-header__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.compact-page-header__row {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.compact-page-header__actions {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Stat grid for compact dashboard */
|
||||
.compact-stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
@media (min-width: 480px) {
|
||||
.compact-stats-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.compact-stats-grid {
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.compact-section-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.compact-section-card {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 16px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.compact-section-card__title {
|
||||
margin: 0 0 12px;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* Responsive overrides for shared admin components */
|
||||
@media (max-width: 1279px) {
|
||||
.admin-shell__content {
|
||||
padding: var(--page-padding);
|
||||
}
|
||||
|
||||
.admin-page-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.admin-page-header .admin-btn,
|
||||
.admin-page-header .admin-action-group {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.admin-page-header .admin-action-group {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.admin-page-header .admin-action-group .admin-btn {
|
||||
flex: 1;
|
||||
min-width: fit-content;
|
||||
}
|
||||
|
||||
.admin-summary-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.admin-form-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.admin-info-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.admin-footer-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.admin-footer-actions .admin-btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.admin-pagination {
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.admin-pagination__pages {
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
:root {
|
||||
--header-height: 60px;
|
||||
}
|
||||
|
||||
.admin-summary-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.admin-form__row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.admin-tab {
|
||||
padding: 8px 10px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.admin-modal-backdrop {
|
||||
padding: 0;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.admin-modal {
|
||||
max-width: 100%;
|
||||
max-height: 92dvh;
|
||||
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
|
||||
}
|
||||
|
||||
.checks-page__footer {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.checks-page__footer .admin-action-group {
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.checks-page__footer .admin-action-group .admin-btn {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1279px) {
|
||||
.admin-tab {
|
||||
padding: 10px 12px;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) and (max-width: 1279px) {
|
||||
.admin-form-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { formatNumber } from '@/utils/format'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
data: number[]
|
||||
title?: string
|
||||
subtitle?: string
|
||||
height?: number
|
||||
color?: string
|
||||
unit?: string
|
||||
labels?: string[]
|
||||
}>(),
|
||||
{
|
||||
height: 160,
|
||||
color: '#17379f',
|
||||
unit: 'میلیارد تومان',
|
||||
},
|
||||
)
|
||||
|
||||
const hoverIndex = ref<number | null>(null)
|
||||
|
||||
const chartWidth = 320
|
||||
const chartHeight = 120
|
||||
const padding = { top: 12, right: 8, bottom: 8, left: 8 }
|
||||
|
||||
const maxValue = computed(() => Math.max(...props.data, 0.001))
|
||||
|
||||
const points = computed(() => {
|
||||
const innerW = chartWidth - padding.left - padding.right
|
||||
const innerH = chartHeight - padding.top - padding.bottom
|
||||
const step = innerW / Math.max(props.data.length - 1, 1)
|
||||
|
||||
return props.data.map((value, index) => ({
|
||||
x: padding.left + index * step,
|
||||
y: padding.top + innerH - (value / maxValue.value) * innerH,
|
||||
value,
|
||||
index,
|
||||
}))
|
||||
})
|
||||
|
||||
const linePoints = computed(() => points.value.map((p) => `${p.x},${p.y}`).join(' '))
|
||||
|
||||
const areaPoints = computed(() => {
|
||||
const bottom = chartHeight - padding.bottom
|
||||
const first = points.value[0]
|
||||
const last = points.value[points.value.length - 1]
|
||||
if (!first || !last) return ''
|
||||
return `${first.x},${bottom} ${linePoints.value} ${last.x},${bottom}`
|
||||
})
|
||||
|
||||
const gridLines = computed(() => {
|
||||
const innerH = chartHeight - padding.top - padding.bottom
|
||||
return [0, 0.5, 1].map((ratio) => padding.top + innerH * ratio)
|
||||
})
|
||||
|
||||
const hoveredPoint = computed(() =>
|
||||
hoverIndex.value === null ? null : points.value[hoverIndex.value] ?? null,
|
||||
)
|
||||
|
||||
function onMove(event: MouseEvent) {
|
||||
const svg = event.currentTarget as SVGSVGElement
|
||||
const rect = svg.getBoundingClientRect()
|
||||
const x = ((event.clientX - rect.left) / rect.width) * chartWidth
|
||||
let nearest = 0
|
||||
let minDist = Infinity
|
||||
points.value.forEach((point) => {
|
||||
const dist = Math.abs(point.x - x)
|
||||
if (dist < minDist) {
|
||||
minDist = dist
|
||||
nearest = point.index
|
||||
}
|
||||
})
|
||||
hoverIndex.value = nearest
|
||||
}
|
||||
|
||||
function onLeave() {
|
||||
hoverIndex.value = null
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="sales-chart">
|
||||
<div v-if="title || subtitle" class="sales-chart__header">
|
||||
<div>
|
||||
<h3 v-if="title" class="sales-chart__title">{{ title }}</h3>
|
||||
<p v-if="subtitle" class="sales-chart__subtitle">{{ subtitle }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sales-chart__body" :style="{ height: `${height}px` }">
|
||||
<svg
|
||||
class="sales-chart__svg"
|
||||
:viewBox="`0 0 ${chartWidth} ${chartHeight}`"
|
||||
preserveAspectRatio="none"
|
||||
role="img"
|
||||
:aria-label="title || 'نمودار فروش'"
|
||||
@mousemove="onMove"
|
||||
@mouseleave="onLeave"
|
||||
>
|
||||
<line
|
||||
v-for="(y, index) in gridLines"
|
||||
:key="index"
|
||||
:x1="padding.left"
|
||||
:x2="chartWidth - padding.right"
|
||||
:y1="y"
|
||||
:y2="y"
|
||||
class="sales-chart__grid"
|
||||
/>
|
||||
|
||||
<polygon v-if="areaPoints" :points="areaPoints" class="sales-chart__area" :style="{ fill: `${color}22` }" />
|
||||
<polyline :points="linePoints" class="sales-chart__line" :style="{ stroke: color }" />
|
||||
|
||||
<template v-if="hoveredPoint">
|
||||
<line
|
||||
:x1="hoveredPoint.x"
|
||||
:x2="hoveredPoint.x"
|
||||
:y1="padding.top"
|
||||
:y2="chartHeight - padding.bottom"
|
||||
class="sales-chart__cursor"
|
||||
/>
|
||||
<circle :cx="hoveredPoint.x" :cy="hoveredPoint.y" r="4" class="sales-chart__dot" :style="{ fill: color }" />
|
||||
</template>
|
||||
</svg>
|
||||
|
||||
<div
|
||||
v-if="hoveredPoint"
|
||||
class="sales-chart__tooltip"
|
||||
:style="{ left: `${(hoveredPoint.x / chartWidth) * 100}%` }"
|
||||
>
|
||||
<span class="sales-chart__tooltip-label">
|
||||
{{ labels?.[hoveredPoint.index] ?? `روز ${hoveredPoint.index + 1}` }}
|
||||
</span>
|
||||
<strong>{{ formatNumber(hoveredPoint.value) }} {{ unit }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="labels?.length" class="sales-chart__axis">
|
||||
<span v-for="(label, index) in labels" :key="index">{{ label }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.sales-chart__header {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.sales-chart__title {
|
||||
margin: 0;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.sales-chart__subtitle {
|
||||
margin: 4px 0 0;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.sales-chart__body {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sales-chart__svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.sales-chart__grid {
|
||||
stroke: var(--color-border);
|
||||
stroke-width: 1;
|
||||
stroke-dasharray: 4 4;
|
||||
}
|
||||
|
||||
.sales-chart__line {
|
||||
fill: none;
|
||||
stroke-width: 2.25;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.sales-chart__cursor {
|
||||
stroke: var(--color-border);
|
||||
stroke-width: 1;
|
||||
stroke-dasharray: 3 3;
|
||||
}
|
||||
|
||||
.sales-chart__dot {
|
||||
stroke: #fff;
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.sales-chart__tooltip {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
transform: translateX(-50%);
|
||||
min-width: 120px;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
box-shadow: var(--shadow-sm);
|
||||
pointer-events: none;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sales-chart__tooltip-label {
|
||||
display: block;
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-muted);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.sales-chart__tooltip strong {
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.sales-chart__axis {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 8px;
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,194 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
name: string
|
||||
size?: number
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<svg
|
||||
:width="size ?? 20"
|
||||
:height="size ?? 20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.75"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<!-- home -->
|
||||
<template v-if="name === 'home'">
|
||||
<path d="M3 10.5 12 3l9 7.5V21a1 1 0 0 1-1 1h-5v-7H9v7H4a1 1 0 0 1-1-1z" />
|
||||
</template>
|
||||
<!-- orders -->
|
||||
<template v-else-if="name === 'orders'">
|
||||
<rect x="5" y="3" width="14" height="18" rx="2" />
|
||||
<path d="M9 8h6M9 12h6M9 16h4" />
|
||||
</template>
|
||||
<!-- customers -->
|
||||
<template v-else-if="name === 'customers'">
|
||||
<circle cx="9" cy="8" r="3" />
|
||||
<circle cx="17" cy="9" r="2.5" />
|
||||
<path d="M3 20c0-3 2.7-5 6-5s6 2 6 5M14 20c0-2 1.5-3.5 3.5-3.5" />
|
||||
</template>
|
||||
<!-- products -->
|
||||
<template v-else-if="name === 'products'">
|
||||
<path d="M12 2 22 7v10l-10 5L2 17V7z" />
|
||||
<path d="M12 22V12M2 7l10 5 10-5" />
|
||||
</template>
|
||||
<!-- catalogs -->
|
||||
<template v-else-if="name === 'catalogs'">
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M12 3a15 15 0 0 1 0 18M12 3a15 15 0 0 0 0 18" />
|
||||
</template>
|
||||
<!-- companies -->
|
||||
<template v-else-if="name === 'companies'">
|
||||
<rect x="4" y="4" width="7" height="16" rx="1" />
|
||||
<rect x="13" y="8" width="7" height="12" rx="1" />
|
||||
<path d="M7 8h1M7 12h1M7 16h1M16 12h1M16 16h1" />
|
||||
</template>
|
||||
<!-- checks -->
|
||||
<template v-else-if="name === 'checks'">
|
||||
<rect x="3" y="6" width="18" height="12" rx="2" />
|
||||
<path d="M3 10h18M7 14h4" />
|
||||
</template>
|
||||
<!-- cash -->
|
||||
<template v-else-if="name === 'cash'">
|
||||
<rect x="2" y="6" width="20" height="12" rx="2" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</template>
|
||||
<!-- shipping -->
|
||||
<template v-else-if="name === 'shipping'">
|
||||
<path d="M3 6h11v8H3zM14 10h4l3 4v2h-7z" />
|
||||
<circle cx="7" cy="18" r="2" />
|
||||
<circle cx="18" cy="18" r="2" />
|
||||
</template>
|
||||
<!-- reports -->
|
||||
<template v-else-if="name === 'reports'">
|
||||
<path d="M4 20V10M10 20V4M16 20v-8M22 20H2" />
|
||||
</template>
|
||||
<!-- settings -->
|
||||
<template v-else-if="name === 'settings'">
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path
|
||||
d="M12 2v2M12 20v2M4.2 4.2l1.4 1.4M18.4 18.4l1.4 1.4M2 12h2M20 12h2M4.2 19.8l1.4-1.4M18.4 5.6l1.4-1.4"
|
||||
/>
|
||||
</template>
|
||||
<!-- users -->
|
||||
<template v-else-if="name === 'users'">
|
||||
<circle cx="10" cy="9" r="3" />
|
||||
<path d="M3 20c0-3 2.2-5 7-5M16 11l2 2 4-4" />
|
||||
</template>
|
||||
<!-- logout -->
|
||||
<template v-else-if="name === 'logout'">
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9" />
|
||||
</template>
|
||||
<!-- search -->
|
||||
<template v-else-if="name === 'search'">
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<path d="m20 20-3.5-3.5" />
|
||||
</template>
|
||||
<!-- bell -->
|
||||
<template v-else-if="name === 'bell'">
|
||||
<path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9" />
|
||||
<path d="M13.73 21a2 2 0 0 1-3.46 0" />
|
||||
</template>
|
||||
<!-- message -->
|
||||
<template v-else-if="name === 'message'">
|
||||
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
||||
<path d="M8 10h8M8 14h5" />
|
||||
</template>
|
||||
<!-- calendar -->
|
||||
<template v-else-if="name === 'calendar'">
|
||||
<rect x="3" y="5" width="18" height="16" rx="2" />
|
||||
<path d="M3 10h18M8 3v4M16 3v4" />
|
||||
</template>
|
||||
<!-- menu -->
|
||||
<template v-else-if="name === 'menu'">
|
||||
<path d="M4 6h16M4 12h16M4 18h16" />
|
||||
</template>
|
||||
<!-- filter -->
|
||||
<template v-else-if="name === 'filter'">
|
||||
<path d="M4 6h16M7 12h10M10 18h4" />
|
||||
</template>
|
||||
<!-- more -->
|
||||
<template v-else-if="name === 'more'">
|
||||
<circle cx="12" cy="6" r="1.5" fill="currentColor" stroke="none" />
|
||||
<circle cx="12" cy="12" r="1.5" fill="currentColor" stroke="none" />
|
||||
<circle cx="12" cy="18" r="1.5" fill="currentColor" stroke="none" />
|
||||
</template>
|
||||
<!-- chevron-left -->
|
||||
<template v-else-if="name === 'chevron-left'">
|
||||
<path d="m15 6-6 6 6 6" />
|
||||
</template>
|
||||
<!-- chevron-down -->
|
||||
<template v-else-if="name === 'chevron-down'">
|
||||
<path d="m6 9 6 6 6-6" />
|
||||
</template>
|
||||
<!-- print -->
|
||||
<template v-else-if="name === 'print'">
|
||||
<path d="M7 8V3h10v5M7 17H5a2 2 0 0 1-2-2v-5h18v5a2 2 0 0 1-2 2h-2M7 14h10v7H7z" />
|
||||
</template>
|
||||
<!-- sms -->
|
||||
<template v-else-if="name === 'sms'">
|
||||
<rect x="3" y="5" width="18" height="14" rx="2" />
|
||||
<path d="M7 9h10M7 13h6" />
|
||||
</template>
|
||||
<!-- check -->
|
||||
<template v-else-if="name === 'check'">
|
||||
<path d="M20 6 9 17l-5-5" />
|
||||
</template>
|
||||
<!-- cart -->
|
||||
<template v-else-if="name === 'cart'">
|
||||
<circle cx="9" cy="20" r="1" />
|
||||
<circle cx="18" cy="20" r="1" />
|
||||
<path d="M2 2h3l2.5 12H19l2-8H6" />
|
||||
</template>
|
||||
<!-- file -->
|
||||
<template v-else-if="name === 'file'">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
||||
<path d="M14 2v6h6" />
|
||||
</template>
|
||||
<!-- wallet -->
|
||||
<template v-else-if="name === 'wallet'">
|
||||
<rect x="2" y="6" width="20" height="14" rx="2" />
|
||||
<path d="M16 12h2" />
|
||||
</template>
|
||||
<!-- check-circle -->
|
||||
<template v-else-if="name === 'check-circle'">
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="m8 12 3 3 5-5" />
|
||||
</template>
|
||||
<!-- phone -->
|
||||
<template v-else-if="name === 'phone'">
|
||||
<path
|
||||
d="M6.6 10.8a15 15 0 0 0 6.6 6.6l2.2-2.2a1 1 0 0 1 1-.25 11 11 0 0 0 3.5.55 1 1 0 0 1 1 1V20a1 1 0 0 1-1 1A16 16 0 0 1 3 5a1 1 0 0 1 1-1h3.5a1 1 0 0 1 1 1 11 11 0 0 0 .55 3.5 1 1 0 0 1-.25 1z"
|
||||
/>
|
||||
</template>
|
||||
<!-- id-card -->
|
||||
<template v-else-if="name === 'id-card'">
|
||||
<rect x="3" y="5" width="18" height="14" rx="2" />
|
||||
<circle cx="9" cy="12" r="2" />
|
||||
<path d="M14 10h5M14 14h5" />
|
||||
</template>
|
||||
<!-- user -->
|
||||
<template v-else-if="name === 'user'">
|
||||
<circle cx="12" cy="8" r="4" />
|
||||
<path d="M4 20c0-4 3.6-7 8-7s8 3 8 7" />
|
||||
</template>
|
||||
<!-- building -->
|
||||
<template v-else-if="name === 'building'">
|
||||
<rect x="5" y="3" width="14" height="18" rx="1" />
|
||||
<path d="M9 7h1M9 11h1M9 15h1M14 7h1M14 11h1M14 15h1" />
|
||||
</template>
|
||||
<!-- trash -->
|
||||
<template v-else-if="name === 'trash'">
|
||||
<path d="M4 7h16M10 11v6M14 11v6M6 7l1 13h10l1-13M9 7V4h6v3" />
|
||||
</template>
|
||||
<!-- default -->
|
||||
<template v-else>
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
</template>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,561 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import AdminIcon from '@/components/icons/AdminIcon.vue'
|
||||
import { adminNotifications, internalMessages } from '@/data/mockData'
|
||||
import { formatDateTime } from '@/utils/format'
|
||||
import type { AdminNotification, InternalMessage } from '@/types'
|
||||
|
||||
defineProps<{
|
||||
title?: string
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
menuClick: []
|
||||
}>()
|
||||
|
||||
type OpenPanel = 'messages' | 'notifications' | null
|
||||
|
||||
const router = useRouter()
|
||||
const openPanel = ref<OpenPanel>(null)
|
||||
const actionsRef = ref<HTMLElement | null>(null)
|
||||
|
||||
const messageList = ref<InternalMessage[]>([...internalMessages])
|
||||
const notificationList = ref<AdminNotification[]>([...adminNotifications])
|
||||
|
||||
const unreadMessages = computed(() => messageList.value.filter((m) => !m.isRead).length)
|
||||
const unreadNotifications = computed(() => notificationList.value.filter((n) => !n.isRead).length)
|
||||
|
||||
function togglePanel(panel: Exclude<OpenPanel, null>) {
|
||||
openPanel.value = openPanel.value === panel ? null : panel
|
||||
}
|
||||
|
||||
function closePanel() {
|
||||
openPanel.value = null
|
||||
}
|
||||
|
||||
function onDocumentClick(event: MouseEvent) {
|
||||
if (actionsRef.value && !actionsRef.value.contains(event.target as Node)) {
|
||||
closePanel()
|
||||
}
|
||||
}
|
||||
|
||||
function onDocumentKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
closePanel()
|
||||
}
|
||||
}
|
||||
|
||||
function markMessageRead(message: InternalMessage) {
|
||||
message.isRead = true
|
||||
}
|
||||
|
||||
function markNotificationRead(notification: AdminNotification) {
|
||||
notification.isRead = true
|
||||
}
|
||||
|
||||
function markAllMessagesRead() {
|
||||
messageList.value.forEach((m) => {
|
||||
m.isRead = true
|
||||
})
|
||||
}
|
||||
|
||||
function markAllNotificationsRead() {
|
||||
notificationList.value.forEach((n) => {
|
||||
n.isRead = true
|
||||
})
|
||||
}
|
||||
|
||||
function openNotification(notification: AdminNotification) {
|
||||
markNotificationRead(notification)
|
||||
closePanel()
|
||||
if (notification.link) {
|
||||
router.push(notification.link)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', onDocumentClick)
|
||||
document.addEventListener('keydown', onDocumentKeydown)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('click', onDocumentClick)
|
||||
document.removeEventListener('keydown', onDocumentKeydown)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="admin-header">
|
||||
<div class="admin-header__right">
|
||||
<button
|
||||
type="button"
|
||||
class="admin-header__menu-btn compact-only"
|
||||
aria-label="باز کردن منو"
|
||||
@click="$emit('menuClick')"
|
||||
>
|
||||
<AdminIcon name="menu" :size="22" />
|
||||
</button>
|
||||
<h1 v-if="title" class="admin-header__title">{{ title }}</h1>
|
||||
</div>
|
||||
|
||||
<div ref="actionsRef" class="admin-header__left">
|
||||
<div class="admin-header__dropdown">
|
||||
<button
|
||||
type="button"
|
||||
class="admin-header__icon-btn"
|
||||
:class="{ 'admin-header__icon-btn--active': openPanel === 'messages' }"
|
||||
title="پیامهای داخلی"
|
||||
:aria-label="`پیامهای داخلی — ${unreadMessages} پیام خواندهنشده`"
|
||||
:aria-expanded="openPanel === 'messages'"
|
||||
aria-haspopup="true"
|
||||
@click.stop="togglePanel('messages')"
|
||||
>
|
||||
<AdminIcon name="message" :size="22" />
|
||||
<span v-if="unreadMessages" class="admin-header__badge" aria-hidden="true">
|
||||
{{ unreadMessages }}
|
||||
</span>
|
||||
<span class="admin-header__icon-label">پیام</span>
|
||||
</button>
|
||||
|
||||
<div
|
||||
v-if="openPanel === 'messages'"
|
||||
class="admin-header__panel"
|
||||
role="dialog"
|
||||
aria-label="پیامهای داخلی"
|
||||
@click.stop
|
||||
>
|
||||
<div class="admin-header__panel-header">
|
||||
<h2 class="admin-header__panel-title">پیامهای داخلی</h2>
|
||||
<button
|
||||
v-if="unreadMessages"
|
||||
type="button"
|
||||
class="admin-header__panel-action"
|
||||
@click="markAllMessagesRead"
|
||||
>
|
||||
علامتگذاری همه بهعنوان خواندهشده
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ul v-if="messageList.length" class="admin-header__panel-list">
|
||||
<li
|
||||
v-for="message in messageList"
|
||||
:key="message.id"
|
||||
class="admin-header__panel-item"
|
||||
:class="{ unread: !message.isRead }"
|
||||
>
|
||||
<button type="button" class="admin-header__panel-item-btn" @click="markMessageRead(message)">
|
||||
<span class="admin-header__panel-item-top">
|
||||
<strong>{{ message.subject }}</strong>
|
||||
<time :datetime="message.sentAt">{{ formatDateTime(message.sentAt) }}</time>
|
||||
</span>
|
||||
<span class="admin-header__panel-item-sender">از {{ message.senderName }}</span>
|
||||
<span class="admin-header__panel-item-body">{{ message.preview }}</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p v-else class="admin-header__panel-empty">پیامی وجود ندارد.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-header__dropdown">
|
||||
<button
|
||||
type="button"
|
||||
class="admin-header__icon-btn"
|
||||
:class="{ 'admin-header__icon-btn--active': openPanel === 'notifications' }"
|
||||
title="اعلانها"
|
||||
:aria-label="`اعلانها — ${unreadNotifications} اعلان جدید`"
|
||||
:aria-expanded="openPanel === 'notifications'"
|
||||
aria-haspopup="true"
|
||||
@click.stop="togglePanel('notifications')"
|
||||
>
|
||||
<AdminIcon name="bell" :size="22" />
|
||||
<span v-if="unreadNotifications" class="admin-header__badge" aria-hidden="true">
|
||||
{{ unreadNotifications }}
|
||||
</span>
|
||||
<span class="admin-header__icon-label">اعلان</span>
|
||||
</button>
|
||||
|
||||
<div
|
||||
v-if="openPanel === 'notifications'"
|
||||
class="admin-header__panel"
|
||||
role="dialog"
|
||||
aria-label="اعلانها"
|
||||
@click.stop
|
||||
>
|
||||
<div class="admin-header__panel-header">
|
||||
<h2 class="admin-header__panel-title">اعلانها</h2>
|
||||
<button
|
||||
v-if="unreadNotifications"
|
||||
type="button"
|
||||
class="admin-header__panel-action"
|
||||
@click="markAllNotificationsRead"
|
||||
>
|
||||
علامتگذاری همه بهعنوان خواندهشده
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ul v-if="notificationList.length" class="admin-header__panel-list">
|
||||
<li
|
||||
v-for="notification in notificationList"
|
||||
:key="notification.id"
|
||||
class="admin-header__panel-item"
|
||||
:class="[
|
||||
{ unread: !notification.isRead },
|
||||
notification.tone ? `admin-header__panel-item--${notification.tone}` : '',
|
||||
]"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="admin-header__panel-item-btn"
|
||||
@click="openNotification(notification)"
|
||||
>
|
||||
<span class="admin-header__panel-item-top">
|
||||
<strong>{{ notification.title }}</strong>
|
||||
<time :datetime="notification.sentAt">{{ formatDateTime(notification.sentAt) }}</time>
|
||||
</span>
|
||||
<span class="admin-header__panel-item-body">{{ notification.body }}</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p v-else class="admin-header__panel-empty">اعلانی وجود ندارد.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-header__user">
|
||||
<div class="admin-header__avatar">ع</div>
|
||||
<div class="admin-header__user-info">
|
||||
<strong>علی محمدی</strong>
|
||||
<span>مدیر سیستم</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-header {
|
||||
height: var(--header-height);
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: 0 28px;
|
||||
background: var(--color-surface);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.admin-header__right {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.admin-header__menu-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.admin-header__menu-btn:hover {
|
||||
background: var(--color-border-light);
|
||||
}
|
||||
|
||||
.admin-header__title {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.admin-header__left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.admin-header__dropdown {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.admin-header__icon-btn {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
min-width: 52px;
|
||||
height: 52px;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.admin-header__icon-btn:hover,
|
||||
.admin-header__icon-btn--active {
|
||||
background: var(--color-border-light);
|
||||
border-color: #cbd5e1;
|
||||
}
|
||||
|
||||
.admin-header__icon-label {
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.admin-header__badge {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
padding: 0 5px;
|
||||
border-radius: 999px;
|
||||
background: var(--color-danger);
|
||||
color: #fff;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 2px solid var(--color-surface);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.admin-header__panel {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
left: 0;
|
||||
z-index: 950;
|
||||
width: min(360px, calc(100vw - 32px));
|
||||
max-height: min(420px, calc(100dvh - var(--header-height) - 24px));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-card);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.admin-header__panel-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
background: #fafbfc;
|
||||
}
|
||||
|
||||
.admin-header__panel-title {
|
||||
margin: 0;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.admin-header__panel-action {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-primary);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
padding: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-header__panel-action:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.admin-header__panel-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.admin-header__panel-item {
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.admin-header__panel-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.admin-header__panel-item.unread {
|
||||
background: var(--color-info-light);
|
||||
}
|
||||
|
||||
.admin-header__panel-item-btn {
|
||||
width: 100%;
|
||||
border: none;
|
||||
background: transparent;
|
||||
text-align: right;
|
||||
padding: 12px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.admin-header__panel-item-btn:hover {
|
||||
background: rgba(15, 23, 42, 0.03);
|
||||
}
|
||||
|
||||
.admin-header__panel-item-top {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.admin-header__panel-item-top strong {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.admin-header__panel-item-top time {
|
||||
flex-shrink: 0;
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.admin-header__panel-item-sender {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.admin-header__panel-item-body {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.admin-header__panel-item--warning.unread {
|
||||
background: var(--color-warning-light, #fffbeb);
|
||||
}
|
||||
|
||||
.admin-header__panel-item--danger.unread {
|
||||
background: #fef2f2;
|
||||
}
|
||||
|
||||
.admin-header__panel-item--success.unread {
|
||||
background: #f0fdf4;
|
||||
}
|
||||
|
||||
.admin-header__panel-empty {
|
||||
margin: 0;
|
||||
padding: 24px 16px;
|
||||
text-align: center;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.admin-header__user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding-right: 12px;
|
||||
margin-right: 4px;
|
||||
border-right: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.admin-header__avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.admin-header__user-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.admin-header__user-info strong {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.admin-header__user-info span {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.admin-header {
|
||||
padding: 0 16px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.admin-header__icon-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.admin-header__icon-btn {
|
||||
min-width: 40px;
|
||||
height: 40px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.admin-header__panel {
|
||||
position: fixed;
|
||||
top: var(--header-height);
|
||||
left: 16px;
|
||||
right: 16px;
|
||||
width: auto;
|
||||
max-height: calc(100dvh - var(--header-height) - 16px);
|
||||
}
|
||||
|
||||
.admin-header__panel-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.admin-header__panel-action {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.admin-header__user-info {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.admin-header__user {
|
||||
border-right: none;
|
||||
padding-right: 0;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.admin-header__title {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,141 @@
|
||||
<script setup lang="ts">
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ADMIN_NAV_ITEMS, isNavActive } from '@/utils/navigation'
|
||||
import AdminIcon from '@/components/icons/AdminIcon.vue'
|
||||
|
||||
defineProps<{
|
||||
open: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
function navigate(path: string) {
|
||||
router.push(path)
|
||||
emit('close')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="admin-drawer-backdrop"
|
||||
:class="{ open }"
|
||||
role="presentation"
|
||||
@click="emit('close')"
|
||||
/>
|
||||
<aside
|
||||
class="admin-drawer"
|
||||
:class="{ open }"
|
||||
aria-label="منوی ناوبری"
|
||||
:aria-hidden="!open"
|
||||
>
|
||||
<div class="admin-drawer__brand">
|
||||
<strong>پَر | فام بافت</strong>
|
||||
<span>پنل مدیریت</span>
|
||||
<button type="button" class="admin-drawer__close" aria-label="بستن منو" @click="emit('close')">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<nav class="admin-drawer__nav">
|
||||
<button
|
||||
v-for="item in ADMIN_NAV_ITEMS"
|
||||
:key="item.name"
|
||||
type="button"
|
||||
class="admin-drawer__item"
|
||||
:class="{ active: isNavActive(item.path, route.path) }"
|
||||
@click="navigate(item.path)"
|
||||
>
|
||||
<AdminIcon :name="item.icon" :size="20" />
|
||||
<span>{{ item.label }}</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<button type="button" class="admin-drawer__logout" @click="navigate('/login')">
|
||||
<AdminIcon name="logout" :size="20" />
|
||||
<span>خروج</span>
|
||||
</button>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-drawer__brand {
|
||||
position: relative;
|
||||
padding: 20px 16px;
|
||||
text-align: center;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.admin-drawer__brand strong {
|
||||
display: block;
|
||||
font-size: 1.0625rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.admin-drawer__brand span {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.admin-drawer__close {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
left: 12px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: #fff;
|
||||
font-size: 1.25rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.admin-drawer__nav {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.admin-drawer__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.admin-drawer__item.active {
|
||||
background: var(--color-sidebar-active);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.admin-drawer__logout {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin: 8px 10px 16px;
|
||||
padding: 12px 14px;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,132 @@
|
||||
<script setup lang="ts">
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ADMIN_NAV_ITEMS, isNavActive } from '@/utils/navigation'
|
||||
import AdminIcon from '@/components/icons/AdminIcon.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
function navigate(path: string) {
|
||||
router.push(path)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="admin-sidebar" aria-label="ناوبری پنل مدیریت">
|
||||
<div class="admin-sidebar__brand">
|
||||
<strong class="admin-sidebar__title">پَر | فام بافت</strong>
|
||||
<span class="admin-sidebar__subtitle">پنل مدیریت</span>
|
||||
</div>
|
||||
|
||||
<nav class="admin-sidebar__nav">
|
||||
<button
|
||||
v-for="item in ADMIN_NAV_ITEMS"
|
||||
:key="item.name"
|
||||
type="button"
|
||||
class="admin-sidebar__item"
|
||||
:class="{ active: isNavActive(item.path, route.path) }"
|
||||
@click="navigate(item.path)"
|
||||
>
|
||||
<AdminIcon :name="item.icon" :size="20" />
|
||||
<span>{{ item.label }}</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<button type="button" class="admin-sidebar__logout" @click="navigate('/login')">
|
||||
<AdminIcon name="logout" :size="20" />
|
||||
<span>خروج</span>
|
||||
</button>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-sidebar {
|
||||
width: var(--sidebar-width);
|
||||
flex-shrink: 0;
|
||||
height: 100%;
|
||||
background: var(--color-sidebar-bg);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.admin-sidebar__brand {
|
||||
padding: 24px 20px 20px;
|
||||
text-align: center;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.admin-sidebar__title {
|
||||
display: block;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.admin-sidebar__subtitle {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.admin-sidebar__nav {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 12px 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.admin-sidebar__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
text-align: right;
|
||||
transition: background var(--transition-fast), color var(--transition-fast);
|
||||
}
|
||||
|
||||
.admin-sidebar__item:focus-visible {
|
||||
outline: 2px solid rgba(255, 255, 255, 0.45);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.admin-sidebar__item:hover {
|
||||
background: var(--color-sidebar-hover);
|
||||
}
|
||||
|
||||
.admin-sidebar__item.active {
|
||||
background: var(--color-sidebar-active);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.admin-sidebar__logout {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin: 8px 10px 16px;
|
||||
padding: 10px 14px;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-size: 0.8125rem;
|
||||
transition: background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.admin-sidebar__logout:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: #fff;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,52 @@
|
||||
<script setup lang="ts">
|
||||
import BaseSwitch from '@/components/ui/BaseSwitch.vue'
|
||||
import { mockPersistToggle, useAsyncToggle } from '@/composables/useAsyncToggle'
|
||||
import { confirm } from '@/composables/useConfirm'
|
||||
|
||||
const props = defineProps<{
|
||||
id: string
|
||||
modelValue: boolean
|
||||
entityName: string
|
||||
entityType?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [boolean]
|
||||
}>()
|
||||
|
||||
const { toggle, isLoading, hasError } = useAsyncToggle({
|
||||
onPersist: async () => {
|
||||
await mockPersistToggle()
|
||||
},
|
||||
successMessage: (value) =>
|
||||
value
|
||||
? `${props.entityType ?? 'مورد'} «${props.entityName}» فعال شد.`
|
||||
: `${props.entityType ?? 'مورد'} «${props.entityName}» غیرفعال شد.`,
|
||||
})
|
||||
|
||||
async function onChange(next: boolean) {
|
||||
if (next === props.modelValue) return
|
||||
|
||||
if (!next) {
|
||||
const ok = await confirm({
|
||||
title: `غیرفعالسازی ${props.entityType ?? 'مورد'}`,
|
||||
message: `آیا از غیرفعال کردن «${props.entityName}» مطمئن هستید؟`,
|
||||
tone: 'danger',
|
||||
confirmLabel: 'غیرفعال کردن',
|
||||
})
|
||||
if (!ok) return
|
||||
}
|
||||
|
||||
const result = await toggle(props.id, props.modelValue)
|
||||
emit('update:modelValue', result)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseSwitch
|
||||
:model-value="modelValue"
|
||||
:loading="isLoading(id)"
|
||||
:error="hasError(id)"
|
||||
@update:model-value="onChange"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,90 @@
|
||||
<script setup lang="ts">
|
||||
import AdminIcon from '@/components/icons/AdminIcon.vue'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
icon?: string
|
||||
title: string
|
||||
description?: string
|
||||
compact?: boolean
|
||||
}>(),
|
||||
{
|
||||
icon: 'file',
|
||||
},
|
||||
)
|
||||
|
||||
defineEmits<{
|
||||
action: []
|
||||
secondary: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="base-empty" :class="{ 'base-empty--compact': compact }">
|
||||
<div class="base-empty__icon" aria-hidden="true">
|
||||
<AdminIcon :name="icon" :size="compact ? 28 : 36" />
|
||||
</div>
|
||||
<h3 class="base-empty__title">{{ title }}</h3>
|
||||
<p v-if="description" class="base-empty__description">{{ description }}</p>
|
||||
<div v-if="$slots.default || $slots.secondary" class="base-empty__actions">
|
||||
<slot />
|
||||
<slot name="secondary" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.base-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: 40px 24px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.base-empty--compact {
|
||||
padding: 28px 16px;
|
||||
}
|
||||
|
||||
.base-empty__icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-border-light);
|
||||
color: var(--color-text-muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.base-empty--compact .base-empty__icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.base-empty__title {
|
||||
margin: 0;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.base-empty__description {
|
||||
margin: 0;
|
||||
max-width: 28rem;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.base-empty__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
justify-content: center;
|
||||
margin-top: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,172 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
open: boolean
|
||||
title: string
|
||||
size?: 'md' | 'lg' | 'xl'
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
|
||||
function onBackdropClick(event: MouseEvent) {
|
||||
if (event.target === event.currentTarget) {
|
||||
emit('close')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="open"
|
||||
class="admin-modal-backdrop"
|
||||
role="presentation"
|
||||
@click="onBackdropClick"
|
||||
>
|
||||
<div
|
||||
class="admin-modal"
|
||||
:class="{
|
||||
'admin-modal--lg': size === 'lg',
|
||||
'admin-modal--xl': size === 'xl',
|
||||
}"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
:aria-label="title"
|
||||
@click.stop
|
||||
>
|
||||
<header class="admin-modal__header">
|
||||
<h2 class="admin-modal__title">{{ title }}</h2>
|
||||
<button type="button" class="admin-modal__close" aria-label="بستن" @click="emit('close')">
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
<div class="admin-modal__body">
|
||||
<slot />
|
||||
</div>
|
||||
<footer v-if="$slots.footer" class="admin-modal__footer">
|
||||
<slot name="footer" />
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
background: rgba(15, 23, 42, 0.45);
|
||||
}
|
||||
|
||||
.admin-modal {
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
max-height: calc(100dvh - 48px);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: 0 20px 48px rgba(15, 23, 42, 0.18);
|
||||
}
|
||||
|
||||
.admin-modal--lg {
|
||||
max-width: 560px;
|
||||
}
|
||||
|
||||
.admin-modal--xl {
|
||||
max-width: 920px;
|
||||
}
|
||||
|
||||
.admin-modal__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.admin-modal__title {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.admin-modal__close {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 1.5rem;
|
||||
line-height: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.admin-modal__close:hover {
|
||||
background: var(--color-border-light);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.admin-modal__body {
|
||||
padding: 24px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.admin-modal__footer {
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 10px;
|
||||
padding: 16px 24px;
|
||||
border-top: 1px solid var(--color-border-light);
|
||||
background: #fafbfc;
|
||||
}
|
||||
|
||||
.admin-modal__footer :deep(.admin-btn) {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.admin-modal-backdrop {
|
||||
padding: 0;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.admin-modal {
|
||||
max-width: 100%;
|
||||
max-height: 92dvh;
|
||||
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
|
||||
}
|
||||
|
||||
.admin-modal__header {
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.admin-modal__body {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.admin-modal__footer {
|
||||
flex-direction: row-reverse;
|
||||
gap: 12px;
|
||||
padding: 12px 16px calc(12px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
.admin-modal__footer :deep(.admin-btn) {
|
||||
flex: 1;
|
||||
min-height: 44px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,163 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
modelValue: boolean
|
||||
disabled?: boolean
|
||||
loading?: boolean
|
||||
error?: boolean
|
||||
label?: string
|
||||
activeText?: string
|
||||
inactiveText?: string
|
||||
id?: string
|
||||
}>(),
|
||||
{
|
||||
activeText: 'فعال',
|
||||
inactiveText: 'غیرفعال',
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [boolean]
|
||||
}>()
|
||||
|
||||
const inputId = computed(() => props.id ?? `switch-${crypto.randomUUID().slice(0, 8)}`)
|
||||
const stateText = computed(() => (props.modelValue ? props.activeText : props.inactiveText))
|
||||
|
||||
function onChange(event: Event) {
|
||||
if (props.disabled || props.loading) return
|
||||
emit('update:modelValue', (event.target as HTMLInputElement).checked)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<label class="base-switch" :class="{ 'base-switch--disabled': disabled || loading, 'base-switch--error': error }">
|
||||
<span v-if="label" class="base-switch__label">{{ label }}</span>
|
||||
<span class="base-switch__control">
|
||||
<input
|
||||
:id="inputId"
|
||||
class="base-switch__input"
|
||||
type="checkbox"
|
||||
role="switch"
|
||||
:checked="modelValue"
|
||||
:disabled="disabled || loading"
|
||||
:aria-checked="modelValue"
|
||||
:aria-label="label || stateText"
|
||||
@change="onChange"
|
||||
/>
|
||||
<span class="base-switch__track" aria-hidden="true">
|
||||
<span class="base-switch__thumb">
|
||||
<span v-if="loading" class="base-switch__spinner" />
|
||||
</span>
|
||||
</span>
|
||||
<span class="base-switch__state">{{ stateText }}</span>
|
||||
</span>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.base-switch {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.base-switch--disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.base-switch--error .base-switch__track {
|
||||
box-shadow: 0 0 0 2px rgba(220, 53, 69, 0.18);
|
||||
}
|
||||
|
||||
.base-switch__label {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.base-switch__control {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.base-switch__input {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.base-switch__input:focus-visible + .base-switch__track {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.base-switch__track {
|
||||
position: relative;
|
||||
width: 44px;
|
||||
height: 24px;
|
||||
border-radius: 999px;
|
||||
background: #cbd5e1;
|
||||
transition: background var(--transition-fast);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.base-switch__input:checked + .base-switch__track {
|
||||
background: var(--color-success);
|
||||
}
|
||||
|
||||
.base-switch__thumb {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
box-shadow: var(--shadow-sm);
|
||||
transition: transform var(--transition-fast);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.base-switch__input:checked + .base-switch__track .base-switch__thumb {
|
||||
transform: translateX(-20px);
|
||||
}
|
||||
|
||||
.base-switch__state {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-muted);
|
||||
min-width: 3.5rem;
|
||||
}
|
||||
|
||||
.base-switch__spinner {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 2px solid #e2e8f0;
|
||||
border-top-color: var(--color-primary);
|
||||
border-radius: 50%;
|
||||
animation: base-switch-spin 0.7s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes base-switch-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.base-switch__track,
|
||||
.base-switch__thumb,
|
||||
.base-switch__spinner {
|
||||
transition: none;
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,45 @@
|
||||
<script setup lang="ts">
|
||||
import BaseModal from '@/components/ui/BaseModal.vue'
|
||||
import { resolveConfirm, setConfirmLoading, useConfirmState } from '@/composables/useConfirm'
|
||||
|
||||
const state = useConfirmState()
|
||||
|
||||
function cancel() {
|
||||
resolveConfirm(false)
|
||||
}
|
||||
|
||||
function confirmAction() {
|
||||
setConfirmLoading(true)
|
||||
resolveConfirm(true)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseModal :open="state.open" :title="state.title" size="md" @close="cancel">
|
||||
<p class="confirm-dialog__message">{{ state.message }}</p>
|
||||
<template #footer>
|
||||
<button type="button" class="admin-btn admin-btn--outline" :disabled="state.loading" @click="cancel">
|
||||
{{ state.cancelLabel }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="admin-btn"
|
||||
:class="state.tone === 'danger' ? 'admin-btn--danger' : 'admin-btn--primary'"
|
||||
:disabled="state.loading"
|
||||
@click="confirmAction"
|
||||
>
|
||||
<span v-if="state.loading" class="admin-btn__spinner" aria-hidden="true" />
|
||||
{{ state.confirmLabel }}
|
||||
</button>
|
||||
</template>
|
||||
</BaseModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.confirm-dialog__message {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-muted);
|
||||
line-height: 1.7;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import type { OrderStatus } from '@/types'
|
||||
import { ORDER_STATUS_META } from '@/utils/orderStatus'
|
||||
|
||||
defineProps<{
|
||||
status: OrderStatus | 'active' | 'inactive' | 'pending_check_review'
|
||||
}>()
|
||||
|
||||
const extraMeta: Record<string, { label: string; tone: string }> = {
|
||||
active: { label: 'فعال', tone: 'success' },
|
||||
inactive: { label: 'غیرفعال', tone: 'neutral' },
|
||||
pending_check_review: { label: 'در انتظار بررسی', tone: 'warning' },
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
class="admin-badge status-badge"
|
||||
:class="`admin-badge--${(ORDER_STATUS_META[status as OrderStatus] ?? extraMeta[status])?.tone ?? 'neutral'}`"
|
||||
>
|
||||
{{ (ORDER_STATUS_META[status as OrderStatus] ?? extraMeta[status])?.label ?? status }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.status-badge {
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,106 @@
|
||||
<script setup lang="ts">
|
||||
import { useToast } from '@/composables/useToast'
|
||||
|
||||
const { toasts, dismiss } = useToast()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="toast-container" aria-live="polite" aria-relevant="additions">
|
||||
<TransitionGroup name="toast">
|
||||
<div
|
||||
v-for="toast in toasts"
|
||||
:key="toast.id"
|
||||
class="toast"
|
||||
:class="`toast--${toast.tone}`"
|
||||
role="status"
|
||||
>
|
||||
<p class="toast__message">{{ toast.message }}</p>
|
||||
<button type="button" class="toast__close" aria-label="بستن" @click="dismiss(toast.id)">
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
top: calc(var(--header-height) + 12px);
|
||||
left: 16px;
|
||||
z-index: 1100;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: min(360px, calc(100vw - 32px));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.toast {
|
||||
pointer-events: auto;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.toast--success {
|
||||
border-color: #bbf7d0;
|
||||
background: #f0fdf4;
|
||||
}
|
||||
|
||||
.toast--error {
|
||||
border-color: #fecaca;
|
||||
background: #fef2f2;
|
||||
}
|
||||
|
||||
.toast--warning {
|
||||
border-color: #fde68a;
|
||||
background: #fffbeb;
|
||||
}
|
||||
|
||||
.toast--info {
|
||||
border-color: #bfdbfe;
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
.toast__message {
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.toast__close {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 1.125rem;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toast-enter-active,
|
||||
.toast-leave-active {
|
||||
transition: opacity 0.18s ease, transform 0.18s ease;
|
||||
}
|
||||
|
||||
.toast-enter-from,
|
||||
.toast-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-8px);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.toast-enter-active,
|
||||
.toast-leave-active {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,56 @@
|
||||
import { ref } from 'vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
|
||||
export function mockPersistToggle(delayMs = 480): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
window.setTimeout(resolve, delayMs)
|
||||
})
|
||||
}
|
||||
|
||||
export function useAsyncToggle(options: {
|
||||
onPersist: (value: boolean) => Promise<void>
|
||||
successMessage?: (value: boolean) => string
|
||||
errorMessage?: string
|
||||
confirmOff?: () => Promise<boolean>
|
||||
}) {
|
||||
const loadingId = ref<string | null>(null)
|
||||
const errorId = ref<string | null>(null)
|
||||
const toast = useToast()
|
||||
|
||||
async function toggle(id: string, current: boolean): Promise<boolean> {
|
||||
if (loadingId.value) return current
|
||||
|
||||
const next = !current
|
||||
|
||||
if (!next && options.confirmOff) {
|
||||
const confirmed = await options.confirmOff()
|
||||
if (!confirmed) return current
|
||||
}
|
||||
|
||||
loadingId.value = id
|
||||
errorId.value = null
|
||||
const previous = current
|
||||
|
||||
try {
|
||||
await options.onPersist(next)
|
||||
toast.success(options.successMessage?.(next) ?? (next ? 'وضعیت فعال شد.' : 'وضعیت غیرفعال شد.'))
|
||||
return next
|
||||
} catch {
|
||||
errorId.value = id
|
||||
toast.error(options.errorMessage ?? 'بهروزرسانی وضعیت انجام نشد.')
|
||||
return previous
|
||||
} finally {
|
||||
loadingId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function isLoading(id: string) {
|
||||
return loadingId.value === id
|
||||
}
|
||||
|
||||
function hasError(id: string) {
|
||||
return errorId.value === id
|
||||
}
|
||||
|
||||
return { toggle, isLoading, hasError }
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
export type BreakpointName =
|
||||
| 'xs'
|
||||
| 'sm'
|
||||
| 'md'
|
||||
| 'lg'
|
||||
| 'xl'
|
||||
| '2xl'
|
||||
|
||||
const BREAKPOINTS = {
|
||||
xs: 0,
|
||||
sm: 480,
|
||||
md: 768,
|
||||
lg: 1024,
|
||||
xl: 1280,
|
||||
'2xl': 1440,
|
||||
} as const
|
||||
|
||||
function getBreakpoint(width: number): BreakpointName {
|
||||
if (width >= BREAKPOINTS['2xl']) return '2xl'
|
||||
if (width >= BREAKPOINTS.xl) return 'xl'
|
||||
if (width >= BREAKPOINTS.lg) return 'lg'
|
||||
if (width >= BREAKPOINTS.md) return 'md'
|
||||
if (width >= BREAKPOINTS.sm) return 'sm'
|
||||
return 'xs'
|
||||
}
|
||||
|
||||
export function useBreakpoint() {
|
||||
const width = ref(typeof window !== 'undefined' ? window.innerWidth : 1280)
|
||||
const breakpoint = ref<BreakpointName>(getBreakpoint(width.value))
|
||||
|
||||
function update() {
|
||||
width.value = window.innerWidth
|
||||
breakpoint.value = getBreakpoint(width.value)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
update()
|
||||
window.addEventListener('resize', update, { passive: true })
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', update)
|
||||
})
|
||||
|
||||
const isDesktop = () => breakpoint.value === 'xl' || breakpoint.value === '2xl'
|
||||
const isTablet = () => breakpoint.value === 'md' || breakpoint.value === 'lg'
|
||||
const isMobile = () => breakpoint.value === 'xs' || breakpoint.value === 'sm'
|
||||
const isCompact = () => !isDesktop()
|
||||
|
||||
return {
|
||||
width,
|
||||
breakpoint,
|
||||
isDesktop,
|
||||
isTablet,
|
||||
isMobile,
|
||||
isCompact,
|
||||
BREAKPOINTS,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { reactive } from 'vue'
|
||||
|
||||
export interface ConfirmOptions {
|
||||
title: string
|
||||
message: string
|
||||
confirmLabel?: string
|
||||
cancelLabel?: string
|
||||
tone?: 'danger' | 'primary'
|
||||
}
|
||||
|
||||
interface ConfirmState extends ConfirmOptions {
|
||||
open: boolean
|
||||
loading: boolean
|
||||
resolve: ((value: boolean) => void) | null
|
||||
}
|
||||
|
||||
const state = reactive<ConfirmState>({
|
||||
open: false,
|
||||
loading: false,
|
||||
title: '',
|
||||
message: '',
|
||||
confirmLabel: 'تأیید',
|
||||
cancelLabel: 'انصراف',
|
||||
tone: 'primary',
|
||||
resolve: null,
|
||||
})
|
||||
|
||||
export function useConfirmState() {
|
||||
return state
|
||||
}
|
||||
|
||||
export function confirm(options: ConfirmOptions): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
Object.assign(state, {
|
||||
open: true,
|
||||
loading: false,
|
||||
title: options.title,
|
||||
message: options.message,
|
||||
confirmLabel: options.confirmLabel ?? 'تأیید',
|
||||
cancelLabel: options.cancelLabel ?? 'انصراف',
|
||||
tone: options.tone ?? 'primary',
|
||||
resolve,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export function resolveConfirm(value: boolean) {
|
||||
state.resolve?.(value)
|
||||
state.open = false
|
||||
state.loading = false
|
||||
state.resolve = null
|
||||
}
|
||||
|
||||
export function setConfirmLoading(loading: boolean) {
|
||||
state.loading = loading
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import {
|
||||
checkDeadlines,
|
||||
dashboardStats,
|
||||
orders,
|
||||
salesChartData,
|
||||
} from '@/data/mockData'
|
||||
|
||||
export const statCards = [
|
||||
{
|
||||
label: 'سفارشهای جدید',
|
||||
value: dashboardStats.newOrders,
|
||||
icon: 'cart',
|
||||
tone: 'info',
|
||||
link: '/orders',
|
||||
},
|
||||
{
|
||||
label: 'در انتظار بررسی',
|
||||
value: dashboardStats.pendingReview,
|
||||
icon: 'file',
|
||||
tone: 'warning',
|
||||
link: '/orders?tab=pending_review',
|
||||
},
|
||||
{
|
||||
label: 'در انتظار چک',
|
||||
value: dashboardStats.pendingCheck,
|
||||
icon: 'wallet',
|
||||
tone: 'purple',
|
||||
link: '/orders?tab=pending_check',
|
||||
},
|
||||
{
|
||||
label: 'تأیید نهایی شده',
|
||||
value: dashboardStats.finalApproval,
|
||||
icon: 'check-circle',
|
||||
tone: 'success',
|
||||
link: '/orders?tab=final_approval',
|
||||
},
|
||||
] as const
|
||||
|
||||
export const iconBg: Record<string, string> = {
|
||||
info: '#eef3ff',
|
||||
warning: '#fff6e5',
|
||||
purple: '#ede9fe',
|
||||
success: '#eaf8f1',
|
||||
}
|
||||
|
||||
export const iconColor: Record<string, string> = {
|
||||
info: '#17379f',
|
||||
warning: '#d97706',
|
||||
purple: '#7c3aed',
|
||||
success: '#159660',
|
||||
}
|
||||
|
||||
export function useDashboard() {
|
||||
const router = useRouter()
|
||||
|
||||
const recentOrders = computed(() => orders.slice(0, 5))
|
||||
|
||||
const chartPoints = computed(() => {
|
||||
const w = 280
|
||||
const h = 120
|
||||
const max = Math.max(...salesChartData)
|
||||
const step = w / (salesChartData.length - 1)
|
||||
return salesChartData
|
||||
.map((v, i) => {
|
||||
const x = i * step
|
||||
const y = h - (v / max) * (h - 10) - 5
|
||||
return `${x},${y}`
|
||||
})
|
||||
.join(' ')
|
||||
})
|
||||
|
||||
function goOrders() {
|
||||
router.push('/orders')
|
||||
}
|
||||
|
||||
return {
|
||||
checkDeadlines,
|
||||
dashboardStats,
|
||||
recentOrders,
|
||||
chartPoints,
|
||||
salesChartData,
|
||||
chartAxisLabels: ['۱', '۷', '۱۴', '۲۱', '۲۸'],
|
||||
goOrders,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { computed, inject, provide, reactive, ref, type InjectionKey } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { companies, orders as initialOrders } from '@/data/mockData'
|
||||
import { generateOrderNumber } from '@/utils/format'
|
||||
import type { Order, SettlementMethod } from '@/types'
|
||||
|
||||
import { useToast } from '@/composables/useToast'
|
||||
|
||||
export function useOrdersPage() {
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
|
||||
const orderList = ref<Order[]>([...initialOrders])
|
||||
const activeTab = ref('all')
|
||||
const selectedOrderId = ref<string>('')
|
||||
const searchQuery = ref('')
|
||||
const showAddModal = ref(false)
|
||||
const showDetailPanel = ref(false)
|
||||
const showDetailModal = ref(false)
|
||||
const formError = ref('')
|
||||
|
||||
const emptyForm = () => ({
|
||||
customerName: '',
|
||||
companyId: companies[0]?.id ?? '',
|
||||
settlementMethod: 'check' as SettlementMethod,
|
||||
meterage: 1000,
|
||||
customerPhone: '',
|
||||
})
|
||||
|
||||
const form = reactive(emptyForm())
|
||||
|
||||
const isFormValid = computed(
|
||||
() => form.customerName.trim().length > 0 && form.companyId.length > 0,
|
||||
)
|
||||
|
||||
const filteredOrders = computed(() => {
|
||||
let list = orderList.value
|
||||
if (activeTab.value !== 'all') {
|
||||
list = list.filter((o) => o.status === activeTab.value)
|
||||
}
|
||||
if (searchQuery.value.trim()) {
|
||||
const q = searchQuery.value.trim()
|
||||
list = list.filter(
|
||||
(o) => o.orderNumber.includes(q) || o.customerName.includes(q),
|
||||
)
|
||||
}
|
||||
return list
|
||||
})
|
||||
|
||||
const selectedOrder = computed(() =>
|
||||
orderList.value.find((o) => o.id === selectedOrderId.value) ?? null,
|
||||
)
|
||||
|
||||
function selectOrder(order: Order, openDetail = false) {
|
||||
selectedOrderId.value = order.id
|
||||
if (openDetail) {
|
||||
showDetailPanel.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function openOrderDetail(order: Order) {
|
||||
selectedOrderId.value = order.id
|
||||
showDetailModal.value = true
|
||||
}
|
||||
|
||||
function closeDetailPanel() {
|
||||
showDetailPanel.value = false
|
||||
}
|
||||
|
||||
function closeDetailModal() {
|
||||
showDetailModal.value = false
|
||||
}
|
||||
|
||||
function settlementLabel(method: string) {
|
||||
return method === 'check' ? 'چکی' : 'نقدی'
|
||||
}
|
||||
|
||||
function goPricing(order: Order) {
|
||||
router.push(`/orders/${order.id}/pricing`)
|
||||
}
|
||||
|
||||
function goChecks(order: Order) {
|
||||
router.push(`/orders/${order.id}/checks`)
|
||||
}
|
||||
|
||||
function openAddModal() {
|
||||
Object.assign(form, emptyForm())
|
||||
formError.value = ''
|
||||
showAddModal.value = true
|
||||
}
|
||||
|
||||
function closeAddModal() {
|
||||
showAddModal.value = false
|
||||
formError.value = ''
|
||||
}
|
||||
|
||||
function submitAddOrder() {
|
||||
if (!isFormValid.value) {
|
||||
formError.value = 'نام مشتری و شرکت الزامی هستند.'
|
||||
return
|
||||
}
|
||||
|
||||
const company = companies.find((c) => c.id === form.companyId)
|
||||
const newOrder: Order = {
|
||||
id: `ord-${crypto.randomUUID().slice(0, 8)}`,
|
||||
orderNumber: generateOrderNumber(),
|
||||
customerName: form.customerName.trim(),
|
||||
companyName: company?.name ?? '—',
|
||||
companyId: form.companyId,
|
||||
settlementMethod: form.settlementMethod,
|
||||
totalAmount: null,
|
||||
meterage: form.meterage,
|
||||
status: 'pending_review',
|
||||
createdAt: new Date().toISOString(),
|
||||
customerPhone: form.customerPhone.trim() || undefined,
|
||||
items: [],
|
||||
}
|
||||
|
||||
orderList.value.unshift(newOrder)
|
||||
selectedOrderId.value = newOrder.id
|
||||
toast.success('سفارش جدید ثبت شد.')
|
||||
closeAddModal()
|
||||
}
|
||||
|
||||
return {
|
||||
companies,
|
||||
orderList,
|
||||
activeTab,
|
||||
selectedOrderId,
|
||||
searchQuery,
|
||||
showAddModal,
|
||||
showDetailPanel,
|
||||
showDetailModal,
|
||||
formError,
|
||||
form,
|
||||
isFormValid,
|
||||
filteredOrders,
|
||||
selectedOrder,
|
||||
selectOrder,
|
||||
openOrderDetail,
|
||||
closeDetailPanel,
|
||||
closeDetailModal,
|
||||
settlementLabel,
|
||||
goPricing,
|
||||
goChecks,
|
||||
openAddModal,
|
||||
closeAddModal,
|
||||
submitAddOrder,
|
||||
}
|
||||
}
|
||||
|
||||
export type OrdersPageContext = ReturnType<typeof useOrdersPage>
|
||||
|
||||
export const ORDERS_PAGE_KEY: InjectionKey<OrdersPageContext> = Symbol('orders-page')
|
||||
|
||||
export function provideOrdersPage() {
|
||||
const ctx = useOrdersPage()
|
||||
provide(ORDERS_PAGE_KEY, ctx)
|
||||
return ctx
|
||||
}
|
||||
|
||||
export function useOrdersPageContext() {
|
||||
const ctx = inject(ORDERS_PAGE_KEY)
|
||||
if (!ctx) throw new Error('Orders page context is missing')
|
||||
return ctx
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { reactive } from 'vue'
|
||||
|
||||
export type ToastTone = 'success' | 'error' | 'info' | 'warning'
|
||||
|
||||
export interface ToastItem {
|
||||
id: string
|
||||
message: string
|
||||
tone: ToastTone
|
||||
}
|
||||
|
||||
const state = reactive<{ items: ToastItem[] }>({
|
||||
items: [],
|
||||
})
|
||||
|
||||
function push(message: string, tone: ToastTone) {
|
||||
const id = crypto.randomUUID()
|
||||
state.items.push({ id, message, tone })
|
||||
window.setTimeout(() => dismiss(id), 3200)
|
||||
}
|
||||
|
||||
function dismiss(id: string) {
|
||||
state.items = state.items.filter((item) => item.id !== id)
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
return {
|
||||
toasts: state.items,
|
||||
dismiss,
|
||||
success: (message: string) => push(message, 'success'),
|
||||
error: (message: string) => push(message, 'error'),
|
||||
info: (message: string) => push(message, 'info'),
|
||||
warning: (message: string) => push(message, 'warning'),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,742 @@
|
||||
import type {
|
||||
AdminNotification,
|
||||
AdminUser,
|
||||
BankAccount,
|
||||
CashPayment,
|
||||
Catalog,
|
||||
Check,
|
||||
CheckDeadline,
|
||||
Company,
|
||||
Customer,
|
||||
InternalMessage,
|
||||
Order,
|
||||
Product,
|
||||
Shipment,
|
||||
} from '@/types'
|
||||
|
||||
export const companies: Company[] = [
|
||||
{
|
||||
id: 'company-par',
|
||||
name: 'شرکت پَر',
|
||||
description: 'تولید و عرضه انواع پارچه',
|
||||
displayOrder: 1,
|
||||
isActive: true,
|
||||
logoUrl: null,
|
||||
},
|
||||
{
|
||||
id: 'company-fam-baft',
|
||||
name: 'شرکت فام بافت',
|
||||
description: 'تولید و عرضه انواع پارچه',
|
||||
displayOrder: 2,
|
||||
isActive: true,
|
||||
logoUrl: null,
|
||||
},
|
||||
]
|
||||
|
||||
export const bankAccounts: BankAccount[] = [
|
||||
{
|
||||
id: 'ba-1',
|
||||
companyId: 'company-par',
|
||||
bankName: '1',
|
||||
accountNumber: '1234567890',
|
||||
accountHolder: 'شرکت پَر',
|
||||
cardNumber: '6104 3378 0077 1234',
|
||||
iban: 'IR870120000000012345678901',
|
||||
isActive: true,
|
||||
displayOrder: 1,
|
||||
},
|
||||
{
|
||||
id: 'ba-2',
|
||||
companyId: 'company-par',
|
||||
bankName: '2',
|
||||
accountNumber: '9876543210',
|
||||
accountHolder: 'شرکت پَر',
|
||||
cardNumber: '6037 9912 3456 7890',
|
||||
iban: 'IR620190000000098765432101',
|
||||
isActive: true,
|
||||
displayOrder: 2,
|
||||
},
|
||||
]
|
||||
|
||||
export const orders: Order[] = [
|
||||
{
|
||||
id: 'ord-1',
|
||||
orderNumber: 'ORD-1403-1256',
|
||||
customerName: 'شرکت پَر',
|
||||
companyName: 'شرکت پَر',
|
||||
companyId: 'company-par',
|
||||
settlementMethod: 'check',
|
||||
totalAmount: 2_450_000_000,
|
||||
meterage: 4250,
|
||||
status: 'pending_review',
|
||||
createdAt: '2024-10-25T10:30:00',
|
||||
customerPhone: '۰۹۱۲۱۲۳۴۵۶۷',
|
||||
customerNationalId: '۱۰۱۰۳۴۵۶۷۸۹',
|
||||
customerType: 'عمدهفروش',
|
||||
items: [
|
||||
{
|
||||
id: 'oi-1',
|
||||
catalogCode: '377',
|
||||
productName: 'پارچه آبی',
|
||||
color: 'آبی',
|
||||
requestedMeterage: 2000,
|
||||
confirmedMeterage: 2000,
|
||||
supplyPrice: null,
|
||||
unitDiscount: 0,
|
||||
totalAmount: null,
|
||||
},
|
||||
{
|
||||
id: 'oi-2',
|
||||
catalogCode: '377',
|
||||
productName: 'پارچه مشکی',
|
||||
color: 'مشکی',
|
||||
requestedMeterage: 1500,
|
||||
confirmedMeterage: 1000,
|
||||
supplyPrice: null,
|
||||
unitDiscount: 0,
|
||||
totalAmount: null,
|
||||
},
|
||||
{
|
||||
id: 'oi-3',
|
||||
catalogCode: '378',
|
||||
productName: 'آبی پشت سفید',
|
||||
color: 'آبی پشت سفید',
|
||||
requestedMeterage: 750,
|
||||
confirmedMeterage: 750,
|
||||
supplyPrice: null,
|
||||
unitDiscount: 0,
|
||||
totalAmount: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'ord-2',
|
||||
orderNumber: 'ORD-1403-1255',
|
||||
customerName: 'شرکت فام بافت',
|
||||
companyName: 'شرکت فام بافت',
|
||||
companyId: 'company-fam-baft',
|
||||
settlementMethod: 'check',
|
||||
totalAmount: 1_850_000_000,
|
||||
meterage: 3200,
|
||||
status: 'pending_check',
|
||||
createdAt: '2024-10-24T14:15:00',
|
||||
},
|
||||
{
|
||||
id: 'ord-3',
|
||||
orderNumber: 'ORD-1403-1254',
|
||||
customerName: 'پارچهسرای نیکو',
|
||||
companyName: 'شرکت پَر',
|
||||
companyId: 'company-par',
|
||||
settlementMethod: 'cash',
|
||||
totalAmount: 980_000_000,
|
||||
meterage: 1800,
|
||||
status: 'final_approval',
|
||||
createdAt: '2024-10-23T09:00:00',
|
||||
},
|
||||
{
|
||||
id: 'ord-4',
|
||||
orderNumber: 'ORD-1403-1253',
|
||||
customerName: 'بافتآسان',
|
||||
companyName: 'شرکت فام بافت',
|
||||
companyId: 'company-fam-baft',
|
||||
settlementMethod: 'check',
|
||||
totalAmount: 3_200_000_000,
|
||||
meterage: 5100,
|
||||
status: 'check_correction',
|
||||
createdAt: '2024-10-22T16:45:00',
|
||||
},
|
||||
{
|
||||
id: 'ord-5',
|
||||
orderNumber: 'ORD-1403-1252',
|
||||
customerName: 'شرکت پَر',
|
||||
companyName: 'شرکت پَر',
|
||||
companyId: 'company-par',
|
||||
settlementMethod: 'cash',
|
||||
totalAmount: 750_000_000,
|
||||
meterage: 1200,
|
||||
status: 'delivered',
|
||||
createdAt: '2024-10-21T11:20:00',
|
||||
},
|
||||
{
|
||||
id: 'ord-6',
|
||||
orderNumber: 'ORD-1403-1251',
|
||||
customerName: 'پارچهسرای نیکو',
|
||||
companyName: 'شرکت پَر',
|
||||
companyId: 'company-par',
|
||||
settlementMethod: 'check',
|
||||
totalAmount: 1_600_000_000,
|
||||
meterage: 2900,
|
||||
status: 'pending_review',
|
||||
createdAt: '2024-10-20T08:30:00',
|
||||
},
|
||||
{
|
||||
id: 'ord-7',
|
||||
orderNumber: 'ORD-1403-1250',
|
||||
customerName: 'بافتآسان',
|
||||
companyName: 'شرکت فام بافت',
|
||||
companyId: 'company-fam-baft',
|
||||
settlementMethod: 'check',
|
||||
totalAmount: 2_100_000_000,
|
||||
meterage: 3800,
|
||||
status: 'pending_check',
|
||||
createdAt: '2024-10-19T13:00:00',
|
||||
},
|
||||
]
|
||||
|
||||
export const checks: Check[] = [
|
||||
{
|
||||
id: 'chk-1',
|
||||
orderId: 'ord-1',
|
||||
row: 1,
|
||||
amount: 1_000_000_000,
|
||||
bankName: 'ملت',
|
||||
sayadId: '12345678901234567',
|
||||
dueDate: '1403/07/30',
|
||||
status: 'pending_review',
|
||||
},
|
||||
{
|
||||
id: 'chk-2',
|
||||
orderId: 'ord-1',
|
||||
row: 2,
|
||||
amount: 800_000_000,
|
||||
bankName: 'ملی',
|
||||
sayadId: '23456789012345678',
|
||||
dueDate: '1403/08/05',
|
||||
status: 'pending_review',
|
||||
},
|
||||
{
|
||||
id: 'chk-3',
|
||||
orderId: 'ord-1',
|
||||
row: 3,
|
||||
amount: 650_000_000,
|
||||
bankName: 'ملت',
|
||||
sayadId: '34567890123456789',
|
||||
dueDate: '1403/08/10',
|
||||
status: 'pending_review',
|
||||
},
|
||||
]
|
||||
|
||||
export const checkDeadlines: CheckDeadline[] = [
|
||||
{ id: 'cd-1', label: 'امروز', tone: 'danger', count: 7, amount: 1_250_000_000 },
|
||||
{ id: 'cd-2', label: 'فردا', tone: 'warning', count: 5, amount: 820_000_000 },
|
||||
{ id: 'cd-3', label: '۴ روز دیگر', tone: 'neutral', count: 3, amount: 670_000_000 },
|
||||
]
|
||||
|
||||
export const dashboardStats = {
|
||||
newOrders: 12,
|
||||
pendingReview: 18,
|
||||
pendingCheck: 9,
|
||||
finalApproval: 24,
|
||||
todaySales: 2_450_000_000,
|
||||
todaySalesGrowth: 12,
|
||||
monthSales: 15_860_000_000,
|
||||
}
|
||||
|
||||
export const salesChartData = [
|
||||
0.4, 0.6, 0.5, 0.8, 0.7, 1.0, 0.9, 1.2, 1.1, 1.4, 1.3, 1.6, 1.5, 1.8,
|
||||
1.7, 2.0, 1.9, 1.7, 1.8, 2.0, 1.9, 1.6, 1.8, 2.0, 1.7, 1.9, 1.8, 2.0,
|
||||
]
|
||||
|
||||
export const internalMessages: InternalMessage[] = [
|
||||
{
|
||||
id: 'msg-1',
|
||||
senderName: 'سارا احمدی',
|
||||
subject: 'هماهنگی ارسال سفارش ORD-1403-1256',
|
||||
preview: 'لطفاً وضعیت باربری سفارش مشتری پارچهسرای نیکو را بررسی کنید.',
|
||||
sentAt: '2024-10-25T09:15:00',
|
||||
isRead: false,
|
||||
},
|
||||
{
|
||||
id: 'msg-2',
|
||||
senderName: 'رضا کریمی',
|
||||
subject: 'درخواست اصلاح قیمت',
|
||||
preview: 'مشتری درخواست تخفیف ۵٪ روی متراژ بالای ۳۰۰۰ متر داده است.',
|
||||
sentAt: '2024-10-25T08:40:00',
|
||||
isRead: false,
|
||||
},
|
||||
{
|
||||
id: 'msg-3',
|
||||
senderName: 'مریم حسینی',
|
||||
subject: 'تأیید چکهای سفارش ۱۲۵۴',
|
||||
preview: 'دو فقره چک بررسی شد؛ یک مورد نیاز به اصلاح دارد.',
|
||||
sentAt: '2024-10-24T16:20:00',
|
||||
isRead: false,
|
||||
},
|
||||
{
|
||||
id: 'msg-4',
|
||||
senderName: 'امیر نوری',
|
||||
subject: 'گزارش فروش هفتگی',
|
||||
preview: 'گزارش فروش واحد تهران برای هفته جاری آماده است.',
|
||||
sentAt: '2024-10-24T11:00:00',
|
||||
isRead: false,
|
||||
},
|
||||
{
|
||||
id: 'msg-5',
|
||||
senderName: 'فاطمه رضایی',
|
||||
subject: 'ثبت مشتری جدید',
|
||||
preview: 'مشتری «نساجی آریا» با مدارک کامل ثبت شد.',
|
||||
sentAt: '2024-10-23T14:30:00',
|
||||
isRead: false,
|
||||
},
|
||||
{
|
||||
id: 'msg-6',
|
||||
senderName: 'حسین مرادی',
|
||||
subject: 'یادآوری جلسه',
|
||||
preview: 'جلسه هماهنگی انبار فردا ساعت ۱۰ برگزار میشود.',
|
||||
sentAt: '2024-10-22T09:00:00',
|
||||
isRead: true,
|
||||
},
|
||||
]
|
||||
|
||||
export const adminNotifications: AdminNotification[] = [
|
||||
{
|
||||
id: 'ntf-1',
|
||||
title: 'سفارش جدید ثبت شد',
|
||||
body: 'سفارش ORD-1403-1260 از مشتری پارچهسرای نیکو در انتظار بررسی است.',
|
||||
sentAt: '2024-10-25T10:05:00',
|
||||
isRead: false,
|
||||
tone: 'info',
|
||||
link: '/orders',
|
||||
},
|
||||
{
|
||||
id: 'ntf-2',
|
||||
title: 'چک در انتظار تأیید',
|
||||
body: '۳ فقره چک برای سفارش ORD-1403-1254 نیاز به بررسی دارد.',
|
||||
sentAt: '2024-10-25T09:50:00',
|
||||
isRead: false,
|
||||
tone: 'warning',
|
||||
link: '/orders/ord-1/checks',
|
||||
},
|
||||
{
|
||||
id: 'ntf-3',
|
||||
title: 'پیشفاکتور ارسال شد',
|
||||
body: 'پیشفاکتور سفارش ORD-1403-1252 برای مشتری ارسال گردید.',
|
||||
sentAt: '2024-10-25T09:20:00',
|
||||
isRead: false,
|
||||
tone: 'success',
|
||||
},
|
||||
{
|
||||
id: 'ntf-4',
|
||||
title: 'سررسید چک نزدیک است',
|
||||
body: '۷ فقره چک با سررسید امروز — مجموع ۱٬۲۵۰٬۰۰۰٬۰۰۰ تومان.',
|
||||
sentAt: '2024-10-25T08:00:00',
|
||||
isRead: false,
|
||||
tone: 'danger',
|
||||
},
|
||||
{
|
||||
id: 'ntf-5',
|
||||
title: 'درخواست اصلاح سفارش',
|
||||
body: 'مشتری برای سفارش ORD-1403-1248 درخواست تغییر متراژ داده است.',
|
||||
sentAt: '2024-10-24T17:30:00',
|
||||
isRead: false,
|
||||
tone: 'warning',
|
||||
},
|
||||
{
|
||||
id: 'ntf-6',
|
||||
title: 'حساب بانکی جدید',
|
||||
body: 'حساب بانکی جدید برای شرکت فام بافت ثبت شد.',
|
||||
sentAt: '2024-10-24T15:00:00',
|
||||
isRead: false,
|
||||
tone: 'info',
|
||||
link: '/companies',
|
||||
},
|
||||
{
|
||||
id: 'ntf-7',
|
||||
title: 'تأیید نهایی سفارش',
|
||||
body: 'سفارش ORD-1403-1245 آماده ارسال است.',
|
||||
sentAt: '2024-10-24T12:45:00',
|
||||
isRead: false,
|
||||
tone: 'success',
|
||||
},
|
||||
{
|
||||
id: 'ntf-8',
|
||||
title: 'پیامک ارسال نشد',
|
||||
body: 'ارسال پیامک برای مشتری ۰۹۱۲۱۲۳۴۵۶۷ با خطا مواجه شد.',
|
||||
sentAt: '2024-10-24T11:20:00',
|
||||
isRead: false,
|
||||
tone: 'danger',
|
||||
},
|
||||
{
|
||||
id: 'ntf-9',
|
||||
title: 'سفارش رد شد',
|
||||
body: 'سفارش ORD-1403-1240 توسط واحد مالی رد شد.',
|
||||
sentAt: '2024-10-23T16:00:00',
|
||||
isRead: false,
|
||||
tone: 'warning',
|
||||
},
|
||||
{
|
||||
id: 'ntf-10',
|
||||
title: 'گزارش ماهانه آماده است',
|
||||
body: 'گزارش فروش مهر ۱۴۰۳ قابل مشاهده است.',
|
||||
sentAt: '2024-10-23T10:00:00',
|
||||
isRead: false,
|
||||
tone: 'info',
|
||||
link: '/reports',
|
||||
},
|
||||
{
|
||||
id: 'ntf-11',
|
||||
title: 'محصول جدید به کاتالوگ اضافه شد',
|
||||
body: 'کالیته ۴۰۱ با ۱۲ رنگ جدید ثبت شد.',
|
||||
sentAt: '2024-10-22T14:00:00',
|
||||
isRead: false,
|
||||
tone: 'info',
|
||||
},
|
||||
{
|
||||
id: 'ntf-12',
|
||||
title: 'یادآوری پرداخت نقدی',
|
||||
body: '۲ سفارش نقدی در انتظار تأیید پرداخت هستند.',
|
||||
sentAt: '2024-10-22T09:30:00',
|
||||
isRead: false,
|
||||
tone: 'warning',
|
||||
link: '/cash-payments',
|
||||
},
|
||||
{
|
||||
id: 'ntf-13',
|
||||
title: 'پشتیبانگیری موفق',
|
||||
body: 'پشتیبانگیری شبانه سامانه با موفقیت انجام شد.',
|
||||
sentAt: '2024-10-21T06:00:00',
|
||||
isRead: true,
|
||||
tone: 'success',
|
||||
},
|
||||
]
|
||||
|
||||
export const customers: Customer[] = [
|
||||
{
|
||||
id: 'cust-1',
|
||||
name: 'پارچهسرای نیکو',
|
||||
phone: '۰۹۱۲۱۲۳۴۵۶۷',
|
||||
nationalId: '۱۰۱۰۳۴۵۶۷۸۹',
|
||||
customerType: 'wholesale',
|
||||
companyName: 'شرکت پَر',
|
||||
orderCount: 24,
|
||||
totalMeterage: 18500,
|
||||
isActive: true,
|
||||
registeredAt: '2024-03-12T10:00:00',
|
||||
},
|
||||
{
|
||||
id: 'cust-2',
|
||||
name: 'نساجی آریا',
|
||||
phone: '۰۹۱۳۵۵۵۱۲۳۴',
|
||||
nationalId: '۱۰۳۰۵۶۷۸۹۰۱',
|
||||
customerType: 'wholesale',
|
||||
companyName: 'شرکت فام بافت',
|
||||
orderCount: 11,
|
||||
totalMeterage: 9200,
|
||||
isActive: true,
|
||||
registeredAt: '2024-05-20T14:30:00',
|
||||
},
|
||||
{
|
||||
id: 'cust-3',
|
||||
name: 'فروشگاه پارچه مهر',
|
||||
phone: '۰۲۱۸۸۷۷۶۶۵۵',
|
||||
nationalId: '۰۰۱۲۳۴۵۶۷۸',
|
||||
customerType: 'retail',
|
||||
companyName: 'شرکت پَر',
|
||||
orderCount: 6,
|
||||
totalMeterage: 2100,
|
||||
isActive: true,
|
||||
registeredAt: '2024-07-08T09:15:00',
|
||||
},
|
||||
{
|
||||
id: 'cust-4',
|
||||
name: 'پوشاک سپهر',
|
||||
phone: '۰۹۳۶۹۸۷۶۵۴۳',
|
||||
nationalId: '۱۰۵۰۹۸۷۶۵۴۳',
|
||||
customerType: 'wholesale',
|
||||
companyName: 'شرکت پَر',
|
||||
orderCount: 3,
|
||||
totalMeterage: 4500,
|
||||
isActive: false,
|
||||
registeredAt: '2024-08-01T11:00:00',
|
||||
},
|
||||
]
|
||||
|
||||
export const catalogs: Catalog[] = [
|
||||
{
|
||||
id: 'cat-377',
|
||||
companyId: 'company-par',
|
||||
companyName: 'شرکت پَر',
|
||||
code: '377',
|
||||
name: 'کالیته ۳۷۷',
|
||||
displayOrder: 1,
|
||||
productCount: 8,
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
id: 'cat-378',
|
||||
companyId: 'company-par',
|
||||
companyName: 'شرکت پَر',
|
||||
code: '378',
|
||||
name: 'کالیته ۳۷۸',
|
||||
displayOrder: 2,
|
||||
productCount: 12,
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
id: 'cat-379',
|
||||
companyId: 'company-par',
|
||||
companyName: 'شرکت پَر',
|
||||
code: '379',
|
||||
name: 'کالیته ۳۷۹',
|
||||
displayOrder: 3,
|
||||
productCount: 6,
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
id: 'cat-401',
|
||||
companyId: 'company-fam-baft',
|
||||
companyName: 'شرکت فام بافت',
|
||||
code: '401',
|
||||
name: 'کالیته ۴۰۱',
|
||||
displayOrder: 1,
|
||||
productCount: 10,
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
id: 'cat-402',
|
||||
companyId: 'company-fam-baft',
|
||||
companyName: 'شرکت فام بافت',
|
||||
code: '402',
|
||||
name: 'کالیته ۴۰۲',
|
||||
displayOrder: 2,
|
||||
productCount: 4,
|
||||
isActive: false,
|
||||
},
|
||||
]
|
||||
|
||||
export const products: Product[] = [
|
||||
{
|
||||
id: 'prod-1',
|
||||
catalogId: 'cat-377',
|
||||
catalogCode: '377',
|
||||
companyName: 'شرکت پَر',
|
||||
name: 'پارچه آبی',
|
||||
color: 'آبی',
|
||||
productCode: '377-BLU',
|
||||
minMeterage: 100,
|
||||
orderStep: 50,
|
||||
price: 185000,
|
||||
internalStock: 12000,
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
id: 'prod-2',
|
||||
catalogId: 'cat-377',
|
||||
catalogCode: '377',
|
||||
companyName: 'شرکت پَر',
|
||||
name: 'پارچه مشکی',
|
||||
color: 'مشکی',
|
||||
productCode: '377-BLK',
|
||||
minMeterage: 100,
|
||||
orderStep: 50,
|
||||
price: 192000,
|
||||
internalStock: 8500,
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
id: 'prod-3',
|
||||
catalogId: 'cat-378',
|
||||
catalogCode: '378',
|
||||
companyName: 'شرکت پَر',
|
||||
name: 'آبی پشت سفید',
|
||||
color: 'آبی / سفید',
|
||||
productCode: '378-BW',
|
||||
minMeterage: 200,
|
||||
orderStep: 100,
|
||||
price: 210000,
|
||||
internalStock: 6000,
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
id: 'prod-4',
|
||||
catalogId: 'cat-378',
|
||||
catalogCode: '378',
|
||||
companyName: 'شرکت پَر',
|
||||
name: 'مشکی پشت سفید',
|
||||
color: 'مشکی / سفید',
|
||||
productCode: '378-BLKW',
|
||||
minMeterage: 200,
|
||||
orderStep: 100,
|
||||
price: 215000,
|
||||
internalStock: 4200,
|
||||
isActive: true,
|
||||
},
|
||||
{
|
||||
id: 'prod-5',
|
||||
catalogId: 'cat-401',
|
||||
catalogCode: '401',
|
||||
companyName: 'شرکت فام بافت',
|
||||
name: 'پارچه کرم',
|
||||
color: 'کرم',
|
||||
productCode: '401-CRM',
|
||||
minMeterage: 150,
|
||||
orderStep: 50,
|
||||
price: null,
|
||||
internalStock: 3000,
|
||||
isActive: true,
|
||||
},
|
||||
]
|
||||
|
||||
export const cashPayments: CashPayment[] = [
|
||||
{
|
||||
id: 'cp-1',
|
||||
orderId: 'ord-2',
|
||||
orderNumber: 'ORD-1403-1254',
|
||||
customerName: 'پارچهسرای نیکو',
|
||||
amount: 1_850_000_000,
|
||||
paidAmount: 1_850_000_000,
|
||||
transferDate: '2024-10-25T08:30:00',
|
||||
trackingCode: '۹۸۷۶۵۴۳۲۱۰',
|
||||
expiresAt: '2024-10-25T17:00:00',
|
||||
status: 'pending_review',
|
||||
},
|
||||
{
|
||||
id: 'cp-2',
|
||||
orderId: 'ord-3',
|
||||
orderNumber: 'ORD-1403-1248',
|
||||
customerName: 'نساجی آریا',
|
||||
amount: 920_000_000,
|
||||
paidAmount: 900_000_000,
|
||||
transferDate: '2024-10-24T15:20:00',
|
||||
trackingCode: '۱۲۳۴۵۶۷۸۹۰',
|
||||
expiresAt: '2024-10-24T17:00:00',
|
||||
status: 'pending_review',
|
||||
},
|
||||
{
|
||||
id: 'cp-3',
|
||||
orderId: 'ord-4',
|
||||
orderNumber: 'ORD-1403-1240',
|
||||
customerName: 'فروشگاه پارچه مهر',
|
||||
amount: 450_000_000,
|
||||
paidAmount: 450_000_000,
|
||||
transferDate: '2024-10-23T11:00:00',
|
||||
trackingCode: '۵۵۵۵۱۲۳۴۵۶',
|
||||
expiresAt: '2024-10-23T17:00:00',
|
||||
status: 'approved',
|
||||
},
|
||||
{
|
||||
id: 'cp-4',
|
||||
orderId: 'ord-5',
|
||||
orderNumber: 'ORD-1403-1235',
|
||||
customerName: 'پوشاک سپهر',
|
||||
amount: 680_000_000,
|
||||
paidAmount: null,
|
||||
transferDate: null,
|
||||
trackingCode: '—',
|
||||
expiresAt: '2024-10-22T17:00:00',
|
||||
status: 'expired',
|
||||
},
|
||||
]
|
||||
|
||||
export const shipments: Shipment[] = [
|
||||
{
|
||||
id: 'shp-1',
|
||||
orderId: 'ord-6',
|
||||
orderNumber: 'ORD-1403-1220',
|
||||
customerName: 'پارچهسرای نیکو',
|
||||
carrier: 'باربری تهران-اصفهان',
|
||||
trackingNumber: 'BR-1403-88901',
|
||||
meterage: 3200,
|
||||
status: 'shipped',
|
||||
shippedAt: '2024-10-24T09:00:00',
|
||||
deliveredAt: null,
|
||||
},
|
||||
{
|
||||
id: 'shp-2',
|
||||
orderId: 'ord-7',
|
||||
orderNumber: 'ORD-1403-1215',
|
||||
customerName: 'نساجی آریا',
|
||||
carrier: 'باربری سریع',
|
||||
trackingNumber: 'BR-1403-88902',
|
||||
meterage: 1800,
|
||||
status: 'ready',
|
||||
shippedAt: null,
|
||||
deliveredAt: null,
|
||||
},
|
||||
{
|
||||
id: 'shp-3',
|
||||
orderId: 'ord-8',
|
||||
orderNumber: 'ORD-1403-1208',
|
||||
customerName: 'فروشگاه پارچه مهر',
|
||||
carrier: 'پیک داخلی',
|
||||
trackingNumber: 'PK-1403-0045',
|
||||
meterage: 950,
|
||||
status: 'preparing',
|
||||
shippedAt: null,
|
||||
deliveredAt: null,
|
||||
},
|
||||
{
|
||||
id: 'shp-4',
|
||||
orderId: 'ord-9',
|
||||
orderNumber: 'ORD-1403-1199',
|
||||
customerName: 'پارچهسرای نیکو',
|
||||
carrier: 'باربری تهران-مشهد',
|
||||
trackingNumber: 'BR-1403-88750',
|
||||
meterage: 4100,
|
||||
status: 'delivered',
|
||||
shippedAt: '2024-10-18T10:00:00',
|
||||
deliveredAt: '2024-10-20T16:30:00',
|
||||
},
|
||||
]
|
||||
|
||||
export const adminUsers: AdminUser[] = [
|
||||
{
|
||||
id: 'usr-1',
|
||||
fullName: 'علی محمدی',
|
||||
phone: '۰۹۱۲۱۱۱۲۲۲۲',
|
||||
email: 'admin@fabrictrade.ir',
|
||||
role: 'admin',
|
||||
isActive: true,
|
||||
lastLoginAt: '2024-10-25T08:00:00',
|
||||
},
|
||||
{
|
||||
id: 'usr-2',
|
||||
fullName: 'سارا احمدی',
|
||||
phone: '۰۹۱۳۳۳۳۴۴۴۴',
|
||||
email: 'sales@fabrictrade.ir',
|
||||
role: 'sales',
|
||||
isActive: true,
|
||||
lastLoginAt: '2024-10-25T07:45:00',
|
||||
},
|
||||
{
|
||||
id: 'usr-3',
|
||||
fullName: 'رضا کریمی',
|
||||
phone: '۰۹۱۵۵۵۵۶۶۶۶',
|
||||
email: 'finance@fabrictrade.ir',
|
||||
role: 'finance',
|
||||
isActive: true,
|
||||
lastLoginAt: '2024-10-24T17:10:00',
|
||||
},
|
||||
{
|
||||
id: 'usr-4',
|
||||
fullName: 'مریم حسینی',
|
||||
phone: '۰۹۱۷۷۷۷۸۸۸۸',
|
||||
email: 'warehouse@fabrictrade.ir',
|
||||
role: 'warehouse',
|
||||
isActive: true,
|
||||
lastLoginAt: '2024-10-24T14:00:00',
|
||||
},
|
||||
{
|
||||
id: 'usr-5',
|
||||
fullName: 'امیر نوری',
|
||||
phone: '۰۹۱۹۹۹۹۰۰۰۰',
|
||||
email: 'amir.n@fabrictrade.ir',
|
||||
role: 'sales',
|
||||
isActive: false,
|
||||
lastLoginAt: '2024-09-30T11:00:00',
|
||||
},
|
||||
]
|
||||
|
||||
export const reportSalesByCompany = [
|
||||
{ companyName: 'شرکت پَر', orderCount: 68, totalAmount: 12_450_000_000, meterage: 28500 },
|
||||
{ companyName: 'شرکت فام بافت', orderCount: 35, totalAmount: 6_820_000_000, meterage: 14200 },
|
||||
]
|
||||
|
||||
export const reportOrdersByStatus = [
|
||||
{ status: 'در انتظار بررسی', count: 18 },
|
||||
{ status: 'در انتظار چک', count: 9 },
|
||||
{ status: 'نیاز به اصلاح', count: 6 },
|
||||
{ status: 'تأیید نهایی', count: 24 },
|
||||
{ status: 'ارسال شده', count: 12 },
|
||||
{ status: 'تحویل شده', count: 34 },
|
||||
]
|
||||
@@ -0,0 +1,63 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import AdminSidebar from '@/components/layout/AdminSidebar.vue'
|
||||
import AdminHeader from '@/components/layout/AdminHeader.vue'
|
||||
import AdminMobileDrawer from '@/components/layout/AdminMobileDrawer.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const drawerOpen = ref(false)
|
||||
|
||||
const pageTitle = computed(() => (route.meta.title as string) ?? '')
|
||||
|
||||
function openDrawer() {
|
||||
drawerOpen.value = true
|
||||
}
|
||||
|
||||
function closeDrawer() {
|
||||
drawerOpen.value = false
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.path,
|
||||
() => {
|
||||
drawerOpen.value = false
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="admin-shell">
|
||||
<AdminSidebar class="desktop-only admin-shell__sidebar" />
|
||||
<AdminMobileDrawer :open="drawerOpen" @close="closeDrawer" />
|
||||
|
||||
<div class="admin-shell__main">
|
||||
<AdminHeader :title="pageTitle" @menu-click="openDrawer" />
|
||||
<main class="admin-shell__content">
|
||||
<RouterView />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.admin-shell {
|
||||
display: flex;
|
||||
height: 100dvh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.admin-shell__main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.admin-shell__content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 24px 28px;
|
||||
}
|
||||
</style>
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
import '@/assets/styles/main.css'
|
||||
import '@/assets/styles/admin.css'
|
||||
import '@/assets/styles/responsive.css'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,105 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import AdminShell from '@/layouts/AdminShell.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
component: AdminShell,
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'dashboard',
|
||||
component: () => import('@/views/dashboard/DashboardView.vue'),
|
||||
meta: { title: 'داشبورد مدیریتی' },
|
||||
},
|
||||
{
|
||||
path: 'orders',
|
||||
name: 'orders',
|
||||
component: () => import('@/views/orders/OrdersView.vue'),
|
||||
meta: { title: 'سفارشها' },
|
||||
},
|
||||
{
|
||||
path: 'orders/:id/pricing',
|
||||
name: 'order-pricing',
|
||||
component: () => import('@/views/orders/OrderPricingView.vue'),
|
||||
meta: { title: 'قیمتگذاری سفارش' },
|
||||
},
|
||||
{
|
||||
path: 'orders/:id/checks',
|
||||
name: 'order-checks',
|
||||
component: () => import('@/views/checks/CheckManagementView.vue'),
|
||||
meta: { title: 'مدیریت چکها' },
|
||||
},
|
||||
{
|
||||
path: 'companies',
|
||||
name: 'companies',
|
||||
component: () => import('@/views/companies/CompaniesView.vue'),
|
||||
meta: { title: 'مدیریت شرکتها' },
|
||||
},
|
||||
{
|
||||
path: 'companies/:companyId/accounts',
|
||||
name: 'bank-accounts',
|
||||
component: () => import('@/views/companies/BankAccountsView.vue'),
|
||||
meta: { title: 'حسابهای بانکی' },
|
||||
},
|
||||
{
|
||||
path: 'customers',
|
||||
name: 'customers',
|
||||
component: () => import('@/views/customers/CustomersView.vue'),
|
||||
meta: { title: 'مشتریان' },
|
||||
},
|
||||
{
|
||||
path: 'products',
|
||||
name: 'products',
|
||||
component: () => import('@/views/products/ProductsView.vue'),
|
||||
meta: { title: 'محصولات' },
|
||||
},
|
||||
{
|
||||
path: 'catalogs',
|
||||
name: 'catalogs',
|
||||
component: () => import('@/views/catalogs/CatalogsView.vue'),
|
||||
meta: { title: 'کالیتهها' },
|
||||
},
|
||||
{
|
||||
path: 'checks',
|
||||
name: 'checks',
|
||||
redirect: { name: 'order-checks', params: { id: 'ord-1' } },
|
||||
},
|
||||
{
|
||||
path: 'cash-payments',
|
||||
name: 'cash-payments',
|
||||
component: () => import('@/views/cash-payments/CashPaymentsView.vue'),
|
||||
meta: { title: 'پرداختهای نقدی' },
|
||||
},
|
||||
{
|
||||
path: 'shipping',
|
||||
name: 'shipping',
|
||||
component: () => import('@/views/shipping/ShippingView.vue'),
|
||||
meta: { title: 'ارسال و باربری' },
|
||||
},
|
||||
{
|
||||
path: 'reports',
|
||||
name: 'reports',
|
||||
component: () => import('@/views/reports/ReportsView.vue'),
|
||||
meta: { title: 'گزارشها' },
|
||||
},
|
||||
{
|
||||
path: 'settings',
|
||||
name: 'settings',
|
||||
component: () => import('@/views/settings/SettingsView.vue'),
|
||||
meta: { title: 'تنظیمات' },
|
||||
},
|
||||
{
|
||||
path: 'users',
|
||||
name: 'users',
|
||||
component: () => import('@/views/users/UsersView.vue'),
|
||||
meta: { title: 'کاربران و دسترسیها' },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,190 @@
|
||||
export type OrderStatus =
|
||||
| 'pending_review'
|
||||
| 'pending_check'
|
||||
| 'needs_correction'
|
||||
| 'final_approval'
|
||||
| 'shipped'
|
||||
| 'delivered'
|
||||
| 'check_correction'
|
||||
|
||||
export type SettlementMethod = 'check' | 'cash'
|
||||
|
||||
export type CheckStatus = 'pending_review' | 'approved' | 'rejected'
|
||||
|
||||
export interface Company {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
displayOrder: number
|
||||
isActive: boolean
|
||||
logoUrl: string | null
|
||||
}
|
||||
|
||||
export interface BankAccount {
|
||||
id: string
|
||||
companyId: string
|
||||
bankName: string
|
||||
accountNumber: string
|
||||
accountHolder: string
|
||||
cardNumber: string
|
||||
iban: string
|
||||
isActive: boolean
|
||||
displayOrder: number
|
||||
}
|
||||
|
||||
export interface OrderItem {
|
||||
id: string
|
||||
catalogCode: string
|
||||
productName: string
|
||||
color: string
|
||||
requestedMeterage: number
|
||||
confirmedMeterage: number | null
|
||||
supplyPrice: number | null
|
||||
unitDiscount: number
|
||||
totalAmount: number | null
|
||||
}
|
||||
|
||||
export interface Order {
|
||||
id: string
|
||||
orderNumber: string
|
||||
customerName: string
|
||||
companyName: string
|
||||
companyId: string
|
||||
settlementMethod: SettlementMethod
|
||||
totalAmount: number | null
|
||||
meterage: number
|
||||
status: OrderStatus
|
||||
createdAt: string
|
||||
customerPhone?: string
|
||||
customerNationalId?: string
|
||||
customerType?: string
|
||||
items?: OrderItem[]
|
||||
}
|
||||
|
||||
export interface Check {
|
||||
id: string
|
||||
orderId: string
|
||||
row: number
|
||||
amount: number
|
||||
bankName: string
|
||||
sayadId: string
|
||||
dueDate: string
|
||||
status: CheckStatus
|
||||
}
|
||||
|
||||
export interface CheckDeadline {
|
||||
id: string
|
||||
label: string
|
||||
tone: 'danger' | 'warning' | 'neutral'
|
||||
count: number
|
||||
amount: number
|
||||
}
|
||||
|
||||
export interface NavItem {
|
||||
name: string
|
||||
label: string
|
||||
path: string
|
||||
icon: string
|
||||
}
|
||||
|
||||
export interface InternalMessage {
|
||||
id: string
|
||||
senderName: string
|
||||
subject: string
|
||||
preview: string
|
||||
sentAt: string
|
||||
isRead: boolean
|
||||
}
|
||||
|
||||
export interface AdminNotification {
|
||||
id: string
|
||||
title: string
|
||||
body: string
|
||||
sentAt: string
|
||||
isRead: boolean
|
||||
tone?: 'info' | 'warning' | 'success' | 'danger'
|
||||
link?: string
|
||||
}
|
||||
|
||||
export type CustomerType = 'wholesale' | 'retail'
|
||||
|
||||
export interface Customer {
|
||||
id: string
|
||||
name: string
|
||||
phone: string
|
||||
nationalId: string
|
||||
customerType: CustomerType
|
||||
companyName: string
|
||||
orderCount: number
|
||||
totalMeterage: number
|
||||
isActive: boolean
|
||||
registeredAt: string
|
||||
}
|
||||
|
||||
export interface Catalog {
|
||||
id: string
|
||||
companyId: string
|
||||
companyName: string
|
||||
code: string
|
||||
name: string
|
||||
displayOrder: number
|
||||
productCount: number
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
export interface Product {
|
||||
id: string
|
||||
catalogId: string
|
||||
catalogCode: string
|
||||
companyName: string
|
||||
name: string
|
||||
color: string
|
||||
productCode: string
|
||||
minMeterage: number
|
||||
orderStep: number
|
||||
price: number | null
|
||||
internalStock: number
|
||||
isActive: boolean
|
||||
}
|
||||
|
||||
export type CashPaymentStatus = 'pending_review' | 'approved' | 'rejected' | 'expired'
|
||||
|
||||
export interface CashPayment {
|
||||
id: string
|
||||
orderId: string
|
||||
orderNumber: string
|
||||
customerName: string
|
||||
amount: number
|
||||
paidAmount: number | null
|
||||
transferDate: string | null
|
||||
trackingCode: string
|
||||
expiresAt: string
|
||||
status: CashPaymentStatus
|
||||
}
|
||||
|
||||
export type ShipmentStatus = 'preparing' | 'ready' | 'shipped' | 'delivered'
|
||||
|
||||
export interface Shipment {
|
||||
id: string
|
||||
orderId: string
|
||||
orderNumber: string
|
||||
customerName: string
|
||||
carrier: string
|
||||
trackingNumber: string
|
||||
meterage: number
|
||||
status: ShipmentStatus
|
||||
shippedAt: string | null
|
||||
deliveredAt: string | null
|
||||
}
|
||||
|
||||
export type UserRole = 'admin' | 'sales' | 'finance' | 'warehouse'
|
||||
|
||||
export interface AdminUser {
|
||||
id: string
|
||||
fullName: string
|
||||
phone: string
|
||||
email: string
|
||||
role: UserRole
|
||||
isActive: boolean
|
||||
lastLoginAt: string | null
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { CashPaymentStatus, CustomerType, ShipmentStatus, UserRole } from '@/types'
|
||||
|
||||
export const CUSTOMER_TYPE_LABELS: Record<CustomerType, string> = {
|
||||
wholesale: 'عمدهفروش',
|
||||
retail: 'خردهفروش',
|
||||
}
|
||||
|
||||
export const USER_ROLE_LABELS: Record<UserRole, string> = {
|
||||
admin: 'مدیر سیستم',
|
||||
sales: 'واحد فروش',
|
||||
finance: 'واحد مالی',
|
||||
warehouse: 'انبار',
|
||||
}
|
||||
|
||||
export const CASH_PAYMENT_STATUS_LABELS: Record<CashPaymentStatus, string> = {
|
||||
pending_review: 'در انتظار تأیید',
|
||||
approved: 'تأیید شده',
|
||||
rejected: 'رد شده',
|
||||
expired: 'منقضیشده',
|
||||
}
|
||||
|
||||
export const CASH_PAYMENT_STATUS_BADGE: Record<CashPaymentStatus, string> = {
|
||||
pending_review: 'admin-badge--warning',
|
||||
approved: 'admin-badge--success',
|
||||
rejected: 'admin-badge--danger',
|
||||
expired: 'admin-badge--neutral',
|
||||
}
|
||||
|
||||
export const SHIPMENT_STATUS_LABELS: Record<ShipmentStatus, string> = {
|
||||
preparing: 'در حال آمادهسازی',
|
||||
ready: 'آماده ارسال',
|
||||
shipped: 'ارسال شده',
|
||||
delivered: 'تحویل شده',
|
||||
}
|
||||
|
||||
export const SHIPMENT_STATUS_BADGE: Record<ShipmentStatus, string> = {
|
||||
preparing: 'admin-badge--warning',
|
||||
ready: 'admin-badge--info',
|
||||
shipped: 'admin-badge--purple',
|
||||
delivered: 'admin-badge--success',
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
const persianDigits = '۰۱۲۳۴۵۶۷۸۹'
|
||||
const numberFormatter = new Intl.NumberFormat('fa-IR', { useGrouping: true })
|
||||
|
||||
export function formatNumber(value: number): string {
|
||||
if (!Number.isFinite(value)) return '—'
|
||||
return numberFormatter.format(value)
|
||||
}
|
||||
|
||||
export function formatCurrency(value: number): string {
|
||||
return `${formatNumber(value)} تومان`
|
||||
}
|
||||
|
||||
export function formatMeterage(value: number): string {
|
||||
return `${formatNumber(value)} متر`
|
||||
}
|
||||
|
||||
const persianDateTimeFormatter = new Intl.DateTimeFormat('fa-IR-u-ca-persian', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
|
||||
export function formatDateTime(date: string): string {
|
||||
return persianDateTimeFormatter.format(new Date(date))
|
||||
}
|
||||
|
||||
export function toPersianDigits(input: string | number): string {
|
||||
return String(input).replace(/\d/g, (d) => persianDigits[Number(d)]!)
|
||||
}
|
||||
|
||||
export function generateOrderNumber(): string {
|
||||
const year = new Intl.DateTimeFormat('fa-IR-u-ca-persian', { year: 'numeric' }).format(new Date())
|
||||
const persianYear = year.replace(/[^\d]/g, '') || '1403'
|
||||
const seq = Math.floor(Math.random() * 9000) + 1000
|
||||
return `ORD-${persianYear}-${seq}`
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { NavItem } from '@/types'
|
||||
|
||||
export const ADMIN_NAV_ITEMS: NavItem[] = [
|
||||
{ name: 'dashboard', label: 'داشبورد', path: '/', icon: 'home' },
|
||||
{ name: 'orders', label: 'سفارشها', path: '/orders', icon: 'orders' },
|
||||
{ name: 'customers', label: 'مشتریان', path: '/customers', icon: 'customers' },
|
||||
{ name: 'products', label: 'محصولات', path: '/products', icon: 'products' },
|
||||
{ name: 'catalogs', label: 'کالیتهها', path: '/catalogs', icon: 'catalogs' },
|
||||
{ name: 'companies', label: 'شرکتها', path: '/companies', icon: 'companies' },
|
||||
{ name: 'checks', label: 'چکها', path: '/checks', icon: 'checks' },
|
||||
{ name: 'cash-payments', label: 'پرداختهای نقدی', path: '/cash-payments', icon: 'cash' },
|
||||
{ name: 'shipping', label: 'ارسال و باربری', path: '/shipping', icon: 'shipping' },
|
||||
{ name: 'reports', label: 'گزارشها', path: '/reports', icon: 'reports' },
|
||||
{ name: 'settings', label: 'تنظیمات', path: '/settings', icon: 'settings' },
|
||||
{ name: 'users', label: 'کاربران و دسترسیها', path: '/users', icon: 'users' },
|
||||
]
|
||||
|
||||
export function isNavActive(path: string, currentPath: string): boolean {
|
||||
if (path === '/') return currentPath === '/'
|
||||
return currentPath.startsWith(path)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { OrderStatus } from '@/types'
|
||||
|
||||
export interface StatusMeta {
|
||||
label: string
|
||||
tone: 'warning' | 'purple' | 'success' | 'danger' | 'info' | 'neutral' | 'delivered'
|
||||
}
|
||||
|
||||
export const ORDER_STATUS_META: Record<OrderStatus, StatusMeta> = {
|
||||
pending_review: { label: 'در انتظار بررسی', tone: 'warning' },
|
||||
pending_check: { label: 'در انتظار چک', tone: 'purple' },
|
||||
needs_correction: { label: 'نیاز به اصلاح', tone: 'danger' },
|
||||
check_correction: { label: 'نیاز به اصلاح چک', tone: 'warning' },
|
||||
final_approval: { label: 'تأیید نهایی', tone: 'success' },
|
||||
shipped: { label: 'ارسال شده', tone: 'info' },
|
||||
delivered: { label: 'تحویل شده', tone: 'delivered' },
|
||||
}
|
||||
|
||||
export const ORDER_TABS = [
|
||||
{ key: 'all', label: 'همه' },
|
||||
{ key: 'pending_review', label: 'در انتظار بررسی', count: 18 },
|
||||
{ key: 'pending_check', label: 'در انتظار چک', count: 9 },
|
||||
{ key: 'needs_correction', label: 'نیاز به اصلاح', count: 6 },
|
||||
{ key: 'final_approval', label: 'تأیید نهایی', count: 24 },
|
||||
{ key: 'shipped', label: 'ارسال شده', count: 12 },
|
||||
{ key: 'delivered', label: 'تحویل شده', count: 34 },
|
||||
] as const
|
||||
@@ -0,0 +1,175 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import AdminIcon from '@/components/icons/AdminIcon.vue'
|
||||
import { cashPayments as initialPayments } from '@/data/mockData'
|
||||
import {
|
||||
CASH_PAYMENT_STATUS_BADGE,
|
||||
CASH_PAYMENT_STATUS_LABELS,
|
||||
} from '@/utils/adminLabels'
|
||||
import { formatCurrency, formatDateTime } from '@/utils/format'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import type { CashPayment } from '@/types'
|
||||
|
||||
const paymentList = ref<CashPayment[]>([...initialPayments])
|
||||
const searchQuery = ref('')
|
||||
const statusFilter = ref<'all' | CashPayment['status']>('all')
|
||||
const toast = useToast()
|
||||
|
||||
const filteredPayments = computed(() => {
|
||||
let list = paymentList.value
|
||||
if (statusFilter.value !== 'all') {
|
||||
list = list.filter((p) => p.status === statusFilter.value)
|
||||
}
|
||||
const q = searchQuery.value.trim()
|
||||
if (q) {
|
||||
list = list.filter(
|
||||
(p) => p.orderNumber.includes(q) || p.customerName.includes(q) || p.trackingCode.includes(q),
|
||||
)
|
||||
}
|
||||
return list
|
||||
})
|
||||
|
||||
function approvePayment(payment: CashPayment) {
|
||||
payment.status = 'approved'
|
||||
toast.success(`پرداخت سفارش ${payment.orderNumber} تأیید شد.`)
|
||||
}
|
||||
|
||||
function rejectPayment(payment: CashPayment) {
|
||||
payment.status = 'rejected'
|
||||
toast.warning(`پرداخت سفارش ${payment.orderNumber} رد شد.`)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="cash-payments-page">
|
||||
<div class="admin-page-header">
|
||||
<div>
|
||||
<h1 class="admin-page-header__title">پرداختهای نقدی</h1>
|
||||
<p class="admin-page-header__breadcrumb">مالی / پرداختهای نقدی</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cash-payments-page__filters admin-card">
|
||||
<div class="cash-payments-page__search">
|
||||
<AdminIcon name="search" :size="18" />
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="search"
|
||||
class="cash-payments-page__search-input"
|
||||
placeholder="جستجو در شماره سفارش، مشتری، پیگیری..."
|
||||
/>
|
||||
</div>
|
||||
<select v-model="statusFilter" class="admin-input">
|
||||
<option value="all">همه وضعیتها</option>
|
||||
<option value="pending_review">در انتظار تأیید</option>
|
||||
<option value="approved">تأیید شده</option>
|
||||
<option value="rejected">رد شده</option>
|
||||
<option value="expired">منقضیشده</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="cash-payments-page__table admin-card desktop-only">
|
||||
<div class="admin-table-wrap">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>شماره سفارش</th>
|
||||
<th>مشتری</th>
|
||||
<th>مبلغ فاکتور</th>
|
||||
<th>مبلغ واریزی</th>
|
||||
<th>تاریخ واریز</th>
|
||||
<th>شماره پیگیری</th>
|
||||
<th>پایان اعتبار</th>
|
||||
<th>وضعیت</th>
|
||||
<th>عملیات</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="payment in filteredPayments" :key="payment.id">
|
||||
<td>{{ payment.orderNumber }}</td>
|
||||
<td>{{ payment.customerName }}</td>
|
||||
<td>{{ formatCurrency(payment.amount) }}</td>
|
||||
<td>{{ payment.paidAmount ? formatCurrency(payment.paidAmount) : '—' }}</td>
|
||||
<td>{{ payment.transferDate ? formatDateTime(payment.transferDate) : '—' }}</td>
|
||||
<td dir="ltr">{{ payment.trackingCode }}</td>
|
||||
<td>{{ formatDateTime(payment.expiresAt) }}</td>
|
||||
<td>
|
||||
<span class="admin-badge" :class="CASH_PAYMENT_STATUS_BADGE[payment.status]">
|
||||
{{ CASH_PAYMENT_STATUS_LABELS[payment.status] }}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div v-if="payment.status === 'pending_review'" class="admin-action-group">
|
||||
<button type="button" class="admin-btn admin-btn--outline-success admin-btn--xs" @click="approvePayment(payment)">
|
||||
تأیید
|
||||
</button>
|
||||
<button type="button" class="admin-btn admin-btn--outline-danger admin-btn--xs" @click="rejectPayment(payment)">
|
||||
رد
|
||||
</button>
|
||||
</div>
|
||||
<span v-else>—</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="compact-only admin-card-list">
|
||||
<article v-for="payment in filteredPayments" :key="payment.id" class="admin-card-item">
|
||||
<div class="admin-card-item__header">
|
||||
<p class="admin-card-item__title">{{ payment.orderNumber }}</p>
|
||||
<span class="admin-badge" :class="CASH_PAYMENT_STATUS_BADGE[payment.status]">
|
||||
{{ CASH_PAYMENT_STATUS_LABELS[payment.status] }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="admin-card-item__meta">
|
||||
<span>{{ payment.customerName }}</span>
|
||||
<span>{{ formatCurrency(payment.amount) }}</span>
|
||||
<span v-if="payment.paidAmount">واریز: {{ formatCurrency(payment.paidAmount) }}</span>
|
||||
<span>اعتبار تا {{ formatDateTime(payment.expiresAt) }}</span>
|
||||
</div>
|
||||
<div v-if="payment.status === 'pending_review'" class="admin-card-item__actions">
|
||||
<button type="button" class="admin-btn admin-btn--outline-success admin-btn--xs" @click="approvePayment(payment)">
|
||||
تأیید
|
||||
</button>
|
||||
<button type="button" class="admin-btn admin-btn--outline-danger admin-btn--xs" @click="rejectPayment(payment)">
|
||||
رد
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.cash-payments-page__filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 16px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.cash-payments-page__search {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.cash-payments-page__search-input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 0.875rem;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.cash-payments-page__table {
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,308 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import BaseModal from '@/components/ui/BaseModal.vue'
|
||||
import BaseSwitch from '@/components/ui/BaseSwitch.vue'
|
||||
import BaseEmptyState from '@/components/ui/BaseEmptyState.vue'
|
||||
import ActiveSwitchCell from '@/components/ui/ActiveSwitchCell.vue'
|
||||
import AdminIcon from '@/components/icons/AdminIcon.vue'
|
||||
import { catalogs as initialCatalogs, companies } from '@/data/mockData'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import type { Catalog } from '@/types'
|
||||
|
||||
const catalogList = ref<Catalog[]>([...initialCatalogs])
|
||||
const searchQuery = ref('')
|
||||
const companyFilter = ref('all')
|
||||
const modalMode = ref<'add' | 'edit' | null>(null)
|
||||
const editingId = ref<string | null>(null)
|
||||
const formError = ref('')
|
||||
const toast = useToast()
|
||||
|
||||
const emptyForm = () => ({
|
||||
companyId: companies[0]?.id ?? '',
|
||||
code: '',
|
||||
name: '',
|
||||
displayOrder: catalogList.value.length + 1,
|
||||
isActive: true,
|
||||
})
|
||||
|
||||
const form = reactive(emptyForm())
|
||||
|
||||
const isModalOpen = computed(() => modalMode.value !== null)
|
||||
const modalTitle = computed(() => (modalMode.value === 'edit' ? 'ویرایش کالیته' : 'افزودن کالیته'))
|
||||
const isFormValid = computed(() => form.code.trim() && form.name.trim() && form.companyId)
|
||||
const hasActiveFilters = computed(
|
||||
() => searchQuery.value.trim().length > 0 || companyFilter.value !== 'all',
|
||||
)
|
||||
|
||||
const filteredCatalogs = computed(() => {
|
||||
let list = catalogList.value
|
||||
if (companyFilter.value !== 'all') {
|
||||
list = list.filter((c) => c.companyId === companyFilter.value)
|
||||
}
|
||||
const q = searchQuery.value.trim()
|
||||
if (q) {
|
||||
list = list.filter((c) => c.code.includes(q) || c.name.includes(q) || c.companyName.includes(q))
|
||||
}
|
||||
return list
|
||||
})
|
||||
|
||||
function companyName(companyId: string) {
|
||||
return companies.find((c) => c.id === companyId)?.name ?? '—'
|
||||
}
|
||||
|
||||
function openAddModal() {
|
||||
modalMode.value = 'add'
|
||||
editingId.value = null
|
||||
Object.assign(form, emptyForm())
|
||||
formError.value = ''
|
||||
}
|
||||
|
||||
function openEditModal(catalog: Catalog) {
|
||||
modalMode.value = 'edit'
|
||||
editingId.value = catalog.id
|
||||
form.companyId = catalog.companyId
|
||||
form.code = catalog.code
|
||||
form.name = catalog.name
|
||||
form.displayOrder = catalog.displayOrder
|
||||
form.isActive = catalog.isActive
|
||||
formError.value = ''
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
modalMode.value = null
|
||||
editingId.value = null
|
||||
formError.value = ''
|
||||
}
|
||||
|
||||
function submitCatalog() {
|
||||
if (!isFormValid.value) {
|
||||
formError.value = 'کد، نام و شرکت الزامی هستند.'
|
||||
return
|
||||
}
|
||||
|
||||
const payload = {
|
||||
companyId: form.companyId,
|
||||
companyName: companyName(form.companyId),
|
||||
code: form.code.trim(),
|
||||
name: form.name.trim(),
|
||||
displayOrder: Number(form.displayOrder) || 1,
|
||||
isActive: form.isActive,
|
||||
}
|
||||
|
||||
if (modalMode.value === 'edit' && editingId.value) {
|
||||
const index = catalogList.value.findIndex((c) => c.id === editingId.value)
|
||||
if (index !== -1) {
|
||||
catalogList.value[index] = { ...catalogList.value[index]!, ...payload }
|
||||
}
|
||||
toast.success('اطلاعات کالیته بهروزرسانی شد.')
|
||||
} else {
|
||||
catalogList.value.push({
|
||||
id: `cat-${crypto.randomUUID().slice(0, 8)}`,
|
||||
...payload,
|
||||
productCount: 0,
|
||||
})
|
||||
toast.success('کالیته جدید ثبت شد.')
|
||||
}
|
||||
|
||||
closeModal()
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
searchQuery.value = ''
|
||||
companyFilter.value = 'all'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="catalogs-page">
|
||||
<div class="admin-page-header">
|
||||
<div>
|
||||
<h1 class="admin-page-header__title">کالیتهها</h1>
|
||||
<p class="admin-page-header__breadcrumb">مدیریت / کالیتهها</p>
|
||||
</div>
|
||||
<button type="button" class="admin-btn admin-btn--primary" @click="openAddModal">
|
||||
افزودن کالیته
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="catalogs-page__filters admin-card">
|
||||
<div class="catalogs-page__search">
|
||||
<AdminIcon name="search" :size="18" />
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="search"
|
||||
class="catalogs-page__search-input"
|
||||
placeholder="جستجو بر اساس کد یا نام..."
|
||||
/>
|
||||
</div>
|
||||
<select v-model="companyFilter" class="admin-input catalogs-page__company-filter">
|
||||
<option value="all">همه شرکتها</option>
|
||||
<option v-for="company in companies" :key="company.id" :value="company.id">
|
||||
{{ company.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<BaseEmptyState
|
||||
v-if="!filteredCatalogs.length"
|
||||
class="admin-card"
|
||||
icon="catalogs"
|
||||
:title="hasActiveFilters ? 'نتیجهای یافت نشد' : 'کالیتهای ثبت نشده'"
|
||||
:description="
|
||||
hasActiveFilters
|
||||
? 'عبارت جستجو را تغییر دهید یا فیلترها را پاک کنید.'
|
||||
: 'برای شروع، اولین کالیته را ثبت کنید.'
|
||||
"
|
||||
>
|
||||
<button v-if="hasActiveFilters" type="button" class="admin-btn admin-btn--outline admin-btn--sm" @click="clearFilters">
|
||||
پاک کردن فیلترها
|
||||
</button>
|
||||
<button v-else type="button" class="admin-btn admin-btn--primary admin-btn--sm" @click="openAddModal">
|
||||
افزودن کالیته
|
||||
</button>
|
||||
</BaseEmptyState>
|
||||
|
||||
<div v-else class="catalogs-page__table admin-card desktop-only">
|
||||
<div class="admin-table-wrap">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>کد</th>
|
||||
<th>نام کالیته</th>
|
||||
<th>شرکت</th>
|
||||
<th class="numeric">ترتیب</th>
|
||||
<th class="numeric">تعداد محصول</th>
|
||||
<th class="status">وضعیت</th>
|
||||
<th class="actions">عملیات</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="catalog in filteredCatalogs" :key="catalog.id">
|
||||
<td>{{ catalog.code }}</td>
|
||||
<td>{{ catalog.name }}</td>
|
||||
<td>{{ catalog.companyName }}</td>
|
||||
<td class="numeric">{{ catalog.displayOrder }}</td>
|
||||
<td class="numeric">{{ catalog.productCount }}</td>
|
||||
<td class="status">
|
||||
<ActiveSwitchCell
|
||||
:id="catalog.id"
|
||||
v-model="catalog.isActive"
|
||||
:entity-name="catalog.name"
|
||||
entity-type="کالیته"
|
||||
/>
|
||||
</td>
|
||||
<td class="actions">
|
||||
<button type="button" class="admin-btn admin-btn--ghost admin-btn--xs" @click="openEditModal(catalog)">
|
||||
ویرایش
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="filteredCatalogs.length" class="compact-only admin-card-list">
|
||||
<article v-for="catalog in filteredCatalogs" :key="catalog.id" class="admin-card-item">
|
||||
<div class="admin-card-item__header">
|
||||
<p class="admin-card-item__title">کالیته {{ catalog.code }} — {{ catalog.name }}</p>
|
||||
<ActiveSwitchCell
|
||||
:id="catalog.id"
|
||||
v-model="catalog.isActive"
|
||||
:entity-name="catalog.name"
|
||||
entity-type="کالیته"
|
||||
/>
|
||||
</div>
|
||||
<div class="admin-card-item__meta">
|
||||
<span>{{ catalog.companyName }}</span>
|
||||
<span>{{ catalog.productCount }} محصول</span>
|
||||
<span>ترتیب: {{ catalog.displayOrder }}</span>
|
||||
</div>
|
||||
<div class="admin-card-item__actions">
|
||||
<button type="button" class="admin-btn admin-btn--ghost admin-btn--xs" @click="openEditModal(catalog)">
|
||||
ویرایش
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<BaseModal :open="isModalOpen" :title="modalTitle" size="lg" @close="closeModal">
|
||||
<form class="admin-form" @submit.prevent="submitCatalog">
|
||||
<p v-if="formError" class="admin-form__error" role="alert">{{ formError }}</p>
|
||||
|
||||
<div class="admin-form__field">
|
||||
<label for="catalog-company">شرکت <span class="admin-form__required">*</span></label>
|
||||
<select id="catalog-company" v-model="form.companyId" class="admin-input">
|
||||
<option v-for="company in companies" :key="company.id" :value="company.id">
|
||||
{{ company.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="admin-form__row">
|
||||
<div class="admin-form__field">
|
||||
<label for="catalog-code">کد کالیته <span class="admin-form__required">*</span></label>
|
||||
<input id="catalog-code" v-model="form.code" type="text" class="admin-input" placeholder="377" />
|
||||
</div>
|
||||
<div class="admin-form__field">
|
||||
<label for="catalog-name">نام کالیته <span class="admin-form__required">*</span></label>
|
||||
<input id="catalog-name" v-model="form.name" type="text" class="admin-input" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-form__row">
|
||||
<div class="admin-form__field">
|
||||
<label for="catalog-order">ترتیب نمایش</label>
|
||||
<input id="catalog-order" v-model.number="form.displayOrder" type="number" min="1" class="admin-input" />
|
||||
</div>
|
||||
<div class="admin-form__field">
|
||||
<BaseSwitch v-model="form.isActive" label="وضعیت" />
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<template #footer>
|
||||
<button type="button" class="admin-btn admin-btn--outline" @click="closeModal">انصراف</button>
|
||||
<button type="button" class="admin-btn admin-btn--primary" :disabled="!isFormValid" @click="submitCatalog">
|
||||
{{ modalMode === 'edit' ? 'ذخیره تغییرات' : 'ثبت کالیته' }}
|
||||
</button>
|
||||
</template>
|
||||
</BaseModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.catalogs-page__filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 16px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.catalogs-page__search {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.catalogs-page__search-input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 0.875rem;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.catalogs-page__company-filter {
|
||||
width: min(220px, 100%);
|
||||
}
|
||||
|
||||
.catalogs-page__table {
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,211 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import AdminIcon from '@/components/icons/AdminIcon.vue'
|
||||
import StatusBadge from '@/components/ui/StatusBadge.vue'
|
||||
import { checks, orders } from '@/data/mockData'
|
||||
import { formatCurrency, formatNumber } from '@/utils/format'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const activeTab = ref<'info' | 'images' | 'history'>('info')
|
||||
|
||||
const order = computed(() => orders.find((o) => o.id === route.params.id) ?? orders[0]!)
|
||||
|
||||
const orderChecks = computed(() => checks.filter((c) => c.orderId === order.value.id))
|
||||
|
||||
const totalCheckAmount = computed(() =>
|
||||
orderChecks.value.reduce((sum, c) => sum + c.amount, 0),
|
||||
)
|
||||
|
||||
function goBack() {
|
||||
router.push('/orders')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="checks-page">
|
||||
<div class="admin-page-header">
|
||||
<div>
|
||||
<h1 class="admin-page-header__title">مدیریت چکها</h1>
|
||||
<p class="admin-page-header__breadcrumb">سفارشها / مدیریت سفارش</p>
|
||||
</div>
|
||||
<div class="checks-page__order-meta">
|
||||
<strong>{{ order.orderNumber }}</strong>
|
||||
<StatusBadge status="pending_check" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-tabs">
|
||||
<button
|
||||
type="button"
|
||||
class="admin-tab"
|
||||
:class="{ active: activeTab === 'info' }"
|
||||
@click="activeTab = 'info'"
|
||||
>
|
||||
اطلاعات چکها ({{ orderChecks.length }})
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="admin-tab"
|
||||
:class="{ active: activeTab === 'images' }"
|
||||
@click="activeTab = 'images'"
|
||||
>
|
||||
تصاویر چکها
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="admin-tab"
|
||||
:class="{ active: activeTab === 'history' }"
|
||||
@click="activeTab = 'history'"
|
||||
>
|
||||
تاریخچه بررسی
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<template v-if="activeTab === 'info'">
|
||||
<div class="admin-summary-grid">
|
||||
<div class="admin-summary-item">
|
||||
<p class="admin-summary-item__label">تعداد چکها</p>
|
||||
<p class="admin-summary-item__value">{{ orderChecks.length }} فقره</p>
|
||||
</div>
|
||||
<div class="admin-summary-item">
|
||||
<p class="admin-summary-item__label">راس چکها</p>
|
||||
<p class="admin-summary-item__value">
|
||||
<AdminIcon name="check" :size="14" />
|
||||
۲ روز
|
||||
</p>
|
||||
<p class="checks-page__rass-date">تاریخ راس: ۱۴۰۳/۰۸/۰۶</p>
|
||||
</div>
|
||||
<div class="admin-summary-item">
|
||||
<p class="admin-summary-item__label">مجموع چکها</p>
|
||||
<p class="admin-summary-item__value">{{ formatCurrency(totalCheckAmount) }}</p>
|
||||
</div>
|
||||
<div class="admin-summary-item">
|
||||
<p class="admin-summary-item__label">مبلغ کل سفارش</p>
|
||||
<p class="admin-summary-item__value">
|
||||
{{ order.totalAmount ? formatCurrency(order.totalAmount) : '—' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="checks-page__table admin-card desktop-only">
|
||||
<div class="admin-table-wrap">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ردیف</th>
|
||||
<th>مبلغ (تومان)</th>
|
||||
<th>بانک</th>
|
||||
<th>شناسه صیادی</th>
|
||||
<th>تاریخ سررسید</th>
|
||||
<th>وضعیت</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="check in orderChecks" :key="check.id">
|
||||
<td>{{ check.row }}</td>
|
||||
<td>{{ formatNumber(check.amount) }}</td>
|
||||
<td>{{ check.bankName }}</td>
|
||||
<td>{{ check.sayadId }}</td>
|
||||
<td>{{ check.dueDate }}</td>
|
||||
<td><StatusBadge status="pending_check_review" /></td>
|
||||
<td>
|
||||
<div class="admin-action-group">
|
||||
<button type="button" class="admin-btn admin-btn--outline-success admin-btn--xs">
|
||||
تأیید
|
||||
<AdminIcon name="chevron-down" :size="12" />
|
||||
</button>
|
||||
<button type="button" class="admin-btn admin-btn--outline admin-btn--xs">
|
||||
مشاهده
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="compact-only admin-card-list">
|
||||
<article v-for="check in orderChecks" :key="check.id" class="admin-card-item">
|
||||
<div class="admin-card-item__header">
|
||||
<p class="admin-card-item__title">چک {{ check.row }} — {{ formatNumber(check.amount) }} تومان</p>
|
||||
<StatusBadge status="pending_check_review" />
|
||||
</div>
|
||||
<div class="admin-card-item__meta">
|
||||
<span>بانک {{ check.bankName }}</span>
|
||||
<span>سررسید {{ check.dueDate }}</span>
|
||||
<span dir="ltr">{{ check.sayadId }}</span>
|
||||
</div>
|
||||
<div class="admin-card-item__actions">
|
||||
<button type="button" class="admin-btn admin-btn--outline-success admin-btn--xs">
|
||||
تأیید
|
||||
</button>
|
||||
<button type="button" class="admin-btn admin-btn--outline admin-btn--xs">
|
||||
مشاهده
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="checks-page__footer">
|
||||
<p class="checks-page__note">
|
||||
راس چکها بر اساس تاریخ ثبت سفارش محاسبه شده است.
|
||||
</p>
|
||||
<div class="admin-action-group">
|
||||
<button type="button" class="admin-btn admin-btn--success">تأیید همه چکها</button>
|
||||
<button type="button" class="admin-btn admin-btn--outline">
|
||||
<AdminIcon name="print" :size="16" />
|
||||
چاپ لیستها
|
||||
</button>
|
||||
<button type="button" class="admin-btn admin-btn--outline admin-btn--sm" @click="goBack">
|
||||
بازگشت
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else class="admin-empty-placeholder admin-card">
|
||||
{{ activeTab === 'images' ? 'تصاویر چکها' : 'تاریخچه بررسی' }} — بهزودی
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.checks-page__order-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 6px;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.checks-page__rass-date {
|
||||
margin: 4px 0 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.checks-page__table {
|
||||
margin-top: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.checks-page__footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 20px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.checks-page__note {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,262 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import BaseModal from '@/components/ui/BaseModal.vue'
|
||||
import BaseSwitch from '@/components/ui/BaseSwitch.vue'
|
||||
import BaseEmptyState from '@/components/ui/BaseEmptyState.vue'
|
||||
import ActiveSwitchCell from '@/components/ui/ActiveSwitchCell.vue'
|
||||
import { bankAccounts as initialAccounts, companies } from '@/data/mockData'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import type { BankAccount } from '@/types'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
|
||||
const accountList = ref<BankAccount[]>([...initialAccounts])
|
||||
const showAddModal = ref(false)
|
||||
const formError = ref('')
|
||||
|
||||
const company = computed(
|
||||
() => companies.find((c) => c.id === route.params.companyId) ?? companies[0]!,
|
||||
)
|
||||
|
||||
const accounts = computed(() =>
|
||||
accountList.value.filter((a) => a.companyId === company.value.id),
|
||||
)
|
||||
|
||||
const emptyForm = () => ({
|
||||
bankName: '',
|
||||
accountNumber: '',
|
||||
accountHolder: company.value.name,
|
||||
cardNumber: '',
|
||||
iban: '',
|
||||
isActive: true,
|
||||
})
|
||||
|
||||
const form = reactive(emptyForm())
|
||||
|
||||
const isFormValid = computed(
|
||||
() =>
|
||||
form.bankName.trim().length > 0 &&
|
||||
form.accountNumber.trim().length > 0 &&
|
||||
form.accountHolder.trim().length > 0,
|
||||
)
|
||||
|
||||
function openAddModal() {
|
||||
Object.assign(form, emptyForm())
|
||||
formError.value = ''
|
||||
showAddModal.value = true
|
||||
}
|
||||
|
||||
function closeAddModal() {
|
||||
showAddModal.value = false
|
||||
formError.value = ''
|
||||
}
|
||||
|
||||
function submitAddAccount() {
|
||||
if (!isFormValid.value) {
|
||||
formError.value = 'فیلدهای ستارهدار الزامی هستند.'
|
||||
return
|
||||
}
|
||||
|
||||
const companyAccounts = accountList.value.filter((a) => a.companyId === company.value.id)
|
||||
|
||||
accountList.value.push({
|
||||
id: `ba-${crypto.randomUUID().slice(0, 8)}`,
|
||||
companyId: company.value.id,
|
||||
bankName: form.bankName.trim(),
|
||||
accountNumber: form.accountNumber.trim(),
|
||||
accountHolder: form.accountHolder.trim(),
|
||||
cardNumber: form.cardNumber.trim(),
|
||||
iban: form.iban.trim(),
|
||||
isActive: form.isActive,
|
||||
displayOrder: companyAccounts.length + 1,
|
||||
})
|
||||
|
||||
toast.success('حساب بانکی جدید ثبت شد.')
|
||||
closeAddModal()
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
router.push('/companies')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="accounts-page">
|
||||
<div class="admin-page-header">
|
||||
<div>
|
||||
<h1 class="admin-page-header__title">
|
||||
مدیریت حسابهای بانکی — {{ company.name }}
|
||||
</h1>
|
||||
<p class="admin-page-header__breadcrumb">تنظیمات / شرکتها / حسابهای بانکی</p>
|
||||
</div>
|
||||
<div class="admin-action-group">
|
||||
<button type="button" class="admin-btn admin-btn--primary" @click="openAddModal">
|
||||
افزودن حساب
|
||||
</button>
|
||||
<button type="button" class="admin-btn admin-btn--outline admin-btn--sm" @click="goBack">
|
||||
بازگشت
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BaseEmptyState
|
||||
v-if="!accounts.length"
|
||||
class="admin-card"
|
||||
icon="wallet"
|
||||
title="حساب بانکی ثبت نشده"
|
||||
description="برای این شرکت هنوز حساب بانکی ثبت نشده است."
|
||||
>
|
||||
<button type="button" class="admin-btn admin-btn--primary admin-btn--sm" @click="openAddModal">
|
||||
افزودن حساب
|
||||
</button>
|
||||
</BaseEmptyState>
|
||||
|
||||
<div v-else class="accounts-page__table admin-card desktop-only">
|
||||
<div class="admin-table-wrap">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>بانک</th>
|
||||
<th>حساب بانکی</th>
|
||||
<th>نام صاحب</th>
|
||||
<th>شماره کارت</th>
|
||||
<th>شماره شبا</th>
|
||||
<th class="status">وضعیت</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="account in accounts" :key="account.id">
|
||||
<td>{{ account.bankName }}</td>
|
||||
<td>{{ account.accountNumber }}</td>
|
||||
<td>{{ account.accountHolder }}</td>
|
||||
<td>{{ account.cardNumber || '—' }}</td>
|
||||
<td>{{ account.iban || '—' }}</td>
|
||||
<td class="status">
|
||||
<ActiveSwitchCell
|
||||
:id="account.id"
|
||||
v-model="account.isActive"
|
||||
:entity-name="account.bankName"
|
||||
entity-type="حساب بانکی"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="accounts.length" class="compact-only admin-card-list">
|
||||
<article v-for="account in accounts" :key="account.id" class="admin-card-item">
|
||||
<div class="admin-card-item__header">
|
||||
<p class="admin-card-item__title">بانک {{ account.bankName }}</p>
|
||||
<ActiveSwitchCell
|
||||
:id="account.id"
|
||||
v-model="account.isActive"
|
||||
:entity-name="account.bankName"
|
||||
entity-type="حساب بانکی"
|
||||
/>
|
||||
</div>
|
||||
<div class="admin-card-item__meta">
|
||||
<span>{{ account.accountHolder }}</span>
|
||||
<span>حساب: {{ account.accountNumber }}</span>
|
||||
<span v-if="account.cardNumber">کارت: {{ account.cardNumber }}</span>
|
||||
<span v-if="account.iban" dir="ltr">{{ account.iban }}</span>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<BaseModal :open="showAddModal" title="افزودن حساب بانکی" size="lg" @close="closeAddModal">
|
||||
<form class="admin-form" @submit.prevent="submitAddAccount">
|
||||
<p v-if="formError" class="admin-form__error" role="alert">{{ formError }}</p>
|
||||
|
||||
<div class="admin-form__row">
|
||||
<div class="admin-form__field">
|
||||
<label for="bank-name">نام بانک <span class="admin-form__required">*</span></label>
|
||||
<input
|
||||
id="bank-name"
|
||||
v-model="form.bankName"
|
||||
type="text"
|
||||
class="admin-input"
|
||||
placeholder="مثال: ملت"
|
||||
/>
|
||||
</div>
|
||||
<div class="admin-form__field">
|
||||
<label for="account-number">
|
||||
شماره حساب <span class="admin-form__required">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="account-number"
|
||||
v-model="form.accountNumber"
|
||||
type="text"
|
||||
class="admin-input"
|
||||
placeholder="1234567890"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-form__field">
|
||||
<label for="account-holder">
|
||||
نام صاحب حساب <span class="admin-form__required">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="account-holder"
|
||||
v-model="form.accountHolder"
|
||||
type="text"
|
||||
class="admin-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="admin-form__row">
|
||||
<div class="admin-form__field">
|
||||
<label for="card-number">شماره کارت</label>
|
||||
<input
|
||||
id="card-number"
|
||||
v-model="form.cardNumber"
|
||||
type="text"
|
||||
class="admin-input"
|
||||
placeholder="6104 3378 0077 1234"
|
||||
/>
|
||||
</div>
|
||||
<div class="admin-form__field">
|
||||
<label for="iban">شماره شبا</label>
|
||||
<input
|
||||
id="iban"
|
||||
v-model="form.iban"
|
||||
type="text"
|
||||
class="admin-input"
|
||||
placeholder="IR..."
|
||||
dir="ltr"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-form__field">
|
||||
<BaseSwitch v-model="form.isActive" label="وضعیت" />
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<template #footer>
|
||||
<button type="button" class="admin-btn admin-btn--outline" @click="closeAddModal">
|
||||
انصراف
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="admin-btn admin-btn--primary"
|
||||
:disabled="!isFormValid"
|
||||
@click="submitAddAccount"
|
||||
>
|
||||
ثبت حساب
|
||||
</button>
|
||||
</template>
|
||||
</BaseModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.accounts-page__table {
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,510 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import BaseModal from '@/components/ui/BaseModal.vue'
|
||||
import BaseSwitch from '@/components/ui/BaseSwitch.vue'
|
||||
import BaseEmptyState from '@/components/ui/BaseEmptyState.vue'
|
||||
import ActiveSwitchCell from '@/components/ui/ActiveSwitchCell.vue'
|
||||
import { companies as initialCompanies } from '@/data/mockData'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import type { Company } from '@/types'
|
||||
|
||||
const MAX_LOGO_SIZE = 2 * 1024 * 1024
|
||||
const ACCEPTED_LOGO_TYPES = ['image/png', 'image/jpeg', 'image/webp', 'image/svg+xml']
|
||||
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
|
||||
const companyList = ref<Company[]>([...initialCompanies])
|
||||
const modalMode = ref<'add' | 'edit' | null>(null)
|
||||
const editingCompanyId = ref<string | null>(null)
|
||||
const logoPreview = ref<string | null>(null)
|
||||
const logoInputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const emptyForm = () => ({
|
||||
name: '',
|
||||
description: '',
|
||||
displayOrder: companyList.value.length + 1,
|
||||
isActive: true,
|
||||
})
|
||||
|
||||
const form = reactive(emptyForm())
|
||||
const formError = ref('')
|
||||
|
||||
const isModalOpen = computed(() => modalMode.value !== null)
|
||||
const modalTitle = computed(() => (modalMode.value === 'edit' ? 'ویرایش شرکت' : 'افزودن شرکت'))
|
||||
const submitLabel = computed(() => (modalMode.value === 'edit' ? 'ذخیره تغییرات' : 'ثبت شرکت'))
|
||||
const isFormValid = computed(() => form.name.trim().length > 0 && logoPreview.value !== null)
|
||||
|
||||
function resetLogo() {
|
||||
logoPreview.value = null
|
||||
if (logoInputRef.value) {
|
||||
logoInputRef.value.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function openAddModal() {
|
||||
modalMode.value = 'add'
|
||||
editingCompanyId.value = null
|
||||
Object.assign(form, emptyForm())
|
||||
resetLogo()
|
||||
formError.value = ''
|
||||
}
|
||||
|
||||
function openEditModal(company: Company) {
|
||||
modalMode.value = 'edit'
|
||||
editingCompanyId.value = company.id
|
||||
form.name = company.name
|
||||
form.description = company.description === '—' ? '' : company.description
|
||||
form.displayOrder = company.displayOrder
|
||||
form.isActive = company.isActive
|
||||
logoPreview.value = company.logoUrl
|
||||
if (logoInputRef.value) {
|
||||
logoInputRef.value.value = ''
|
||||
}
|
||||
formError.value = ''
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
modalMode.value = null
|
||||
editingCompanyId.value = null
|
||||
formError.value = ''
|
||||
resetLogo()
|
||||
}
|
||||
|
||||
function onLogoSelect(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
if (!ACCEPTED_LOGO_TYPES.includes(file.type)) {
|
||||
formError.value = 'فرمت مجاز: PNG، JPG، WEBP یا SVG'
|
||||
return
|
||||
}
|
||||
|
||||
if (file.size > MAX_LOGO_SIZE) {
|
||||
formError.value = 'حداکثر حجم تصویر ۲ مگابایت است.'
|
||||
return
|
||||
}
|
||||
|
||||
formError.value = ''
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
logoPreview.value = reader.result as string
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
|
||||
function removeLogo() {
|
||||
resetLogo()
|
||||
}
|
||||
|
||||
function submitCompany() {
|
||||
const name = form.name.trim()
|
||||
if (!name) {
|
||||
formError.value = 'نام شرکت الزامی است.'
|
||||
return
|
||||
}
|
||||
|
||||
if (!logoPreview.value) {
|
||||
formError.value = 'آپلود لوگو الزامی است.'
|
||||
return
|
||||
}
|
||||
|
||||
const duplicate = companyList.value.some(
|
||||
(c) => c.name === name && c.id !== editingCompanyId.value,
|
||||
)
|
||||
if (duplicate) {
|
||||
formError.value = 'شرکتی با این نام قبلاً ثبت شده است.'
|
||||
return
|
||||
}
|
||||
|
||||
if (modalMode.value === 'edit' && editingCompanyId.value) {
|
||||
const index = companyList.value.findIndex((c) => c.id === editingCompanyId.value)
|
||||
if (index !== -1) {
|
||||
companyList.value[index] = {
|
||||
...companyList.value[index]!,
|
||||
name,
|
||||
description: form.description.trim() || '—',
|
||||
displayOrder: Number(form.displayOrder) || 1,
|
||||
isActive: form.isActive,
|
||||
logoUrl: logoPreview.value,
|
||||
}
|
||||
}
|
||||
toast.success('اطلاعات شرکت بهروزرسانی شد.')
|
||||
} else {
|
||||
companyList.value.push({
|
||||
id: `company-${crypto.randomUUID().slice(0, 8)}`,
|
||||
name,
|
||||
description: form.description.trim() || '—',
|
||||
displayOrder: Number(form.displayOrder) || companyList.value.length + 1,
|
||||
isActive: form.isActive,
|
||||
logoUrl: logoPreview.value,
|
||||
})
|
||||
toast.success('شرکت جدید ثبت شد.')
|
||||
}
|
||||
|
||||
closeModal()
|
||||
}
|
||||
|
||||
function goAccounts(companyId: string) {
|
||||
router.push(`/companies/${companyId}/accounts`)
|
||||
}
|
||||
|
||||
function companyInitial(name: string) {
|
||||
return name.trim().charAt(0) || '؟'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="companies-page">
|
||||
<div class="admin-page-header">
|
||||
<div>
|
||||
<h1 class="admin-page-header__title">مدیریت شرکتها</h1>
|
||||
<p class="admin-page-header__breadcrumb">تنظیمات / شرکتها</p>
|
||||
</div>
|
||||
<button type="button" class="admin-btn admin-btn--primary" @click="openAddModal">
|
||||
افزودن شرکت
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<BaseEmptyState
|
||||
v-if="!companyList.length"
|
||||
class="admin-card"
|
||||
icon="companies"
|
||||
title="شرکتی ثبت نشده"
|
||||
description="برای شروع، اولین شرکت را ثبت کنید."
|
||||
>
|
||||
<button type="button" class="admin-btn admin-btn--primary admin-btn--sm" @click="openAddModal">
|
||||
افزودن شرکت
|
||||
</button>
|
||||
</BaseEmptyState>
|
||||
|
||||
<div v-else class="companies-page__table admin-card desktop-only">
|
||||
<div class="admin-table-wrap">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>لوگو</th>
|
||||
<th class="numeric">ترتیب</th>
|
||||
<th class="status">وضعیت</th>
|
||||
<th>توضیحات</th>
|
||||
<th>نام شرکت</th>
|
||||
<th class="actions">عملیات</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="company in companyList" :key="company.id">
|
||||
<td>
|
||||
<img
|
||||
v-if="company.logoUrl"
|
||||
:src="company.logoUrl"
|
||||
:alt="`لوگوی ${company.name}`"
|
||||
class="company-logo"
|
||||
/>
|
||||
<div v-else class="company-logo company-logo--fallback">
|
||||
{{ companyInitial(company.name) }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="numeric">{{ company.displayOrder }}</td>
|
||||
<td class="status">
|
||||
<ActiveSwitchCell
|
||||
:id="company.id"
|
||||
v-model="company.isActive"
|
||||
:entity-name="company.name"
|
||||
entity-type="شرکت"
|
||||
/>
|
||||
</td>
|
||||
<td>{{ company.description }}</td>
|
||||
<td>{{ company.name }}</td>
|
||||
<td class="actions">
|
||||
<div class="admin-action-group">
|
||||
<button
|
||||
type="button"
|
||||
class="admin-btn admin-btn--ghost admin-btn--xs"
|
||||
@click="openEditModal(company)"
|
||||
>
|
||||
ویرایش
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="admin-btn admin-btn--outline admin-btn--xs"
|
||||
@click="goAccounts(company.id)"
|
||||
>
|
||||
حسابها
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="companyList.length" class="compact-only admin-card-list">
|
||||
<article v-for="company in companyList" :key="company.id" class="admin-card-item">
|
||||
<div class="admin-card-item__header">
|
||||
<div class="companies-page__card-logo">
|
||||
<img
|
||||
v-if="company.logoUrl"
|
||||
:src="company.logoUrl"
|
||||
:alt="`لوگوی ${company.name}`"
|
||||
class="company-logo"
|
||||
/>
|
||||
<div v-else class="company-logo company-logo--fallback">
|
||||
{{ companyInitial(company.name) }}
|
||||
</div>
|
||||
<div>
|
||||
<p class="admin-card-item__title">{{ company.name }}</p>
|
||||
<p class="companies-page__card-desc">{{ company.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<ActiveSwitchCell
|
||||
:id="company.id"
|
||||
v-model="company.isActive"
|
||||
:entity-name="company.name"
|
||||
entity-type="شرکت"
|
||||
/>
|
||||
</div>
|
||||
<div class="admin-card-item__meta">
|
||||
<span>ترتیب: {{ company.displayOrder }}</span>
|
||||
</div>
|
||||
<div class="admin-card-item__actions">
|
||||
<button type="button" class="admin-btn admin-btn--ghost admin-btn--xs" @click="openEditModal(company)">
|
||||
ویرایش
|
||||
</button>
|
||||
<button type="button" class="admin-btn admin-btn--outline admin-btn--xs" @click="goAccounts(company.id)">
|
||||
حسابها
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<BaseModal :open="isModalOpen" :title="modalTitle" @close="closeModal">
|
||||
<form class="admin-form" @submit.prevent="submitCompany">
|
||||
<p v-if="formError" class="admin-form__error" role="alert">{{ formError }}</p>
|
||||
|
||||
<div class="admin-form__field">
|
||||
<label>لوگو <span class="admin-form__required">*</span></label>
|
||||
<div class="logo-upload">
|
||||
<div v-if="logoPreview" class="logo-upload__preview-wrap">
|
||||
<img :src="logoPreview" alt="پیشنمایش لوگو" class="logo-upload__preview" />
|
||||
<button type="button" class="logo-upload__remove" @click="removeLogo">
|
||||
حذف تصویر
|
||||
</button>
|
||||
</div>
|
||||
<label v-else class="logo-upload__dropzone" for="company-logo-file">
|
||||
<span class="logo-upload__icon">+</span>
|
||||
<span class="logo-upload__text">انتخاب یا رها کردن تصویر لوگو</span>
|
||||
<span class="logo-upload__hint">PNG، JPG، WEBP یا SVG — حداکثر ۲ مگابایت</span>
|
||||
</label>
|
||||
<input
|
||||
id="company-logo-file"
|
||||
ref="logoInputRef"
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp,image/svg+xml"
|
||||
class="logo-upload__input"
|
||||
@change="onLogoSelect"
|
||||
/>
|
||||
<button
|
||||
v-if="logoPreview"
|
||||
type="button"
|
||||
class="admin-btn admin-btn--outline admin-btn--sm logo-upload__change"
|
||||
@click="logoInputRef?.click()"
|
||||
>
|
||||
تغییر تصویر
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-form__field">
|
||||
<label for="company-name">نام شرکت <span class="admin-form__required">*</span></label>
|
||||
<input
|
||||
id="company-name"
|
||||
v-model="form.name"
|
||||
type="text"
|
||||
class="admin-input"
|
||||
placeholder="مثال: شرکت پَر"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="admin-form__field">
|
||||
<label for="company-description">توضیحات</label>
|
||||
<textarea
|
||||
id="company-description"
|
||||
v-model="form.description"
|
||||
class="admin-form__textarea"
|
||||
placeholder="تولید و عرضه انواع پارچه"
|
||||
rows="3"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="admin-form__row">
|
||||
<div class="admin-form__field">
|
||||
<label for="company-order">ترتیب نمایش</label>
|
||||
<input
|
||||
id="company-order"
|
||||
v-model.number="form.displayOrder"
|
||||
type="number"
|
||||
min="1"
|
||||
class="admin-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="admin-form__field">
|
||||
<BaseSwitch v-model="form.isActive" label="وضعیت" />
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<template #footer>
|
||||
<button type="button" class="admin-btn admin-btn--outline" @click="closeModal">
|
||||
انصراف
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="admin-btn admin-btn--primary"
|
||||
:disabled="!isFormValid"
|
||||
@click="submitCompany"
|
||||
>
|
||||
{{ submitLabel }}
|
||||
</button>
|
||||
</template>
|
||||
</BaseModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.companies-page__table {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.companies-page__card-logo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.companies-page__card-desc {
|
||||
margin: 4px 0 0;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-muted);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.company-logo {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: var(--radius-sm);
|
||||
object-fit: contain;
|
||||
background: #f8fafc;
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.company-logo--fallback {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--color-primary);
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.logo-upload {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.logo-upload__input {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.logo-upload__dropzone {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 28px 16px;
|
||||
border: 2px dashed var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-border-light);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
|
||||
.logo-upload__dropzone:hover {
|
||||
border-color: var(--color-primary);
|
||||
background: var(--color-info-light);
|
||||
}
|
||||
|
||||
.logo-upload__icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.5rem;
|
||||
color: var(--color-text-muted);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.logo-upload__text {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.logo-upload__hint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.logo-upload__preview-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 16px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-border-light);
|
||||
}
|
||||
|
||||
.logo-upload__preview {
|
||||
width: 80px;
|
||||
height: 80px;
|
||||
object-fit: contain;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.logo-upload__remove {
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--color-danger);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.logo-upload__remove:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.logo-upload__change {
|
||||
align-self: flex-start;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,300 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import BaseModal from '@/components/ui/BaseModal.vue'
|
||||
import BaseSwitch from '@/components/ui/BaseSwitch.vue'
|
||||
import BaseEmptyState from '@/components/ui/BaseEmptyState.vue'
|
||||
import ActiveSwitchCell from '@/components/ui/ActiveSwitchCell.vue'
|
||||
import AdminIcon from '@/components/icons/AdminIcon.vue'
|
||||
import { customers as initialCustomers } from '@/data/mockData'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { CUSTOMER_TYPE_LABELS } from '@/utils/adminLabels'
|
||||
import { formatDateTime, formatMeterage } from '@/utils/format'
|
||||
import type { Customer, CustomerType } from '@/types'
|
||||
|
||||
const customerList = ref<Customer[]>([...initialCustomers])
|
||||
const searchQuery = ref('')
|
||||
const modalMode = ref<'add' | 'edit' | null>(null)
|
||||
const editingId = ref<string | null>(null)
|
||||
const formError = ref('')
|
||||
const saving = ref(false)
|
||||
const toast = useToast()
|
||||
|
||||
const emptyForm = () => ({
|
||||
name: '',
|
||||
phone: '',
|
||||
nationalId: '',
|
||||
customerType: 'wholesale' as CustomerType,
|
||||
companyName: 'شرکت پَر',
|
||||
isActive: true,
|
||||
})
|
||||
|
||||
const form = reactive(emptyForm())
|
||||
|
||||
const isModalOpen = computed(() => modalMode.value !== null)
|
||||
const modalTitle = computed(() => (modalMode.value === 'edit' ? 'ویرایش مشتری' : 'افزودن مشتری'))
|
||||
const isFormValid = computed(
|
||||
() => form.name.trim() && form.phone.trim() && form.nationalId.trim(),
|
||||
)
|
||||
|
||||
const filteredCustomers = computed(() => {
|
||||
const q = searchQuery.value.trim()
|
||||
if (!q) return customerList.value
|
||||
return customerList.value.filter(
|
||||
(c) =>
|
||||
c.name.includes(q) ||
|
||||
c.phone.includes(q) ||
|
||||
c.nationalId.includes(q) ||
|
||||
c.companyName.includes(q),
|
||||
)
|
||||
})
|
||||
|
||||
function openAddModal() {
|
||||
modalMode.value = 'add'
|
||||
editingId.value = null
|
||||
Object.assign(form, emptyForm())
|
||||
formError.value = ''
|
||||
}
|
||||
|
||||
function openEditModal(customer: Customer) {
|
||||
modalMode.value = 'edit'
|
||||
editingId.value = customer.id
|
||||
form.name = customer.name
|
||||
form.phone = customer.phone
|
||||
form.nationalId = customer.nationalId
|
||||
form.customerType = customer.customerType
|
||||
form.companyName = customer.companyName
|
||||
form.isActive = customer.isActive
|
||||
formError.value = ''
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
modalMode.value = null
|
||||
editingId.value = null
|
||||
formError.value = ''
|
||||
}
|
||||
|
||||
function submitCustomer() {
|
||||
if (!isFormValid.value) {
|
||||
formError.value = 'نام، تلفن و شناسه ملی الزامی هستند.'
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
const payload = {
|
||||
name: form.name.trim(),
|
||||
phone: form.phone.trim(),
|
||||
nationalId: form.nationalId.trim(),
|
||||
customerType: form.customerType,
|
||||
companyName: form.companyName.trim(),
|
||||
isActive: form.isActive,
|
||||
}
|
||||
|
||||
if (modalMode.value === 'edit' && editingId.value) {
|
||||
const index = customerList.value.findIndex((c) => c.id === editingId.value)
|
||||
if (index !== -1) {
|
||||
customerList.value[index] = { ...customerList.value[index]!, ...payload }
|
||||
}
|
||||
} else {
|
||||
customerList.value.unshift({
|
||||
id: `cust-${crypto.randomUUID().slice(0, 8)}`,
|
||||
...payload,
|
||||
orderCount: 0,
|
||||
totalMeterage: 0,
|
||||
registeredAt: new Date().toISOString(),
|
||||
})
|
||||
}
|
||||
|
||||
toast.success(modalMode.value === 'edit' ? 'اطلاعات مشتری بهروزرسانی شد.' : 'مشتری جدید ثبت شد.')
|
||||
saving.value = false
|
||||
closeModal()
|
||||
}
|
||||
|
||||
function clearSearch() {
|
||||
searchQuery.value = ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="customers-page">
|
||||
<div class="admin-page-header">
|
||||
<div>
|
||||
<h1 class="admin-page-header__title">مشتریان</h1>
|
||||
<p class="admin-page-header__breadcrumb">مدیریت / مشتریان</p>
|
||||
</div>
|
||||
<button type="button" class="admin-btn admin-btn--primary" @click="openAddModal">
|
||||
افزودن مشتری
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="customers-page__search admin-card">
|
||||
<AdminIcon name="search" :size="18" />
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="search"
|
||||
class="customers-page__search-input"
|
||||
placeholder="جستجو در نام، تلفن، شناسه ملی..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<BaseEmptyState
|
||||
v-if="!filteredCustomers.length"
|
||||
class="admin-card"
|
||||
icon="customers"
|
||||
:title="searchQuery.trim() ? 'نتیجهای یافت نشد' : 'مشتریای ثبت نشده'"
|
||||
:description="
|
||||
searchQuery.trim()
|
||||
? 'عبارت جستجو را تغییر دهید یا فیلترها را پاک کنید.'
|
||||
: 'برای شروع، اولین مشتری را ثبت کنید.'
|
||||
"
|
||||
>
|
||||
<button v-if="searchQuery.trim()" type="button" class="admin-btn admin-btn--outline admin-btn--sm" @click="clearSearch">
|
||||
پاک کردن جستجو
|
||||
</button>
|
||||
<button v-else type="button" class="admin-btn admin-btn--primary admin-btn--sm" @click="openAddModal">
|
||||
افزودن مشتری
|
||||
</button>
|
||||
</BaseEmptyState>
|
||||
|
||||
<div v-else class="customers-page__table admin-card desktop-only">
|
||||
<div class="admin-table-wrap">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>نام مشتری</th>
|
||||
<th>تلفن</th>
|
||||
<th>شناسه ملی</th>
|
||||
<th>نوع</th>
|
||||
<th>شرکت</th>
|
||||
<th class="numeric">تعداد سفارش</th>
|
||||
<th class="numeric">مجموع متراژ</th>
|
||||
<th class="status">وضعیت</th>
|
||||
<th>تاریخ ثبت</th>
|
||||
<th class="actions">عملیات</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="customer in filteredCustomers" :key="customer.id">
|
||||
<td>{{ customer.name }}</td>
|
||||
<td>{{ customer.phone }}</td>
|
||||
<td>{{ customer.nationalId }}</td>
|
||||
<td>{{ CUSTOMER_TYPE_LABELS[customer.customerType] }}</td>
|
||||
<td>{{ customer.companyName }}</td>
|
||||
<td class="numeric">{{ customer.orderCount }}</td>
|
||||
<td class="numeric">{{ formatMeterage(customer.totalMeterage) }}</td>
|
||||
<td class="status">
|
||||
<ActiveSwitchCell
|
||||
:id="customer.id"
|
||||
v-model="customer.isActive"
|
||||
:entity-name="customer.name"
|
||||
entity-type="مشتری"
|
||||
/>
|
||||
</td>
|
||||
<td>{{ formatDateTime(customer.registeredAt) }}</td>
|
||||
<td class="actions">
|
||||
<button type="button" class="admin-btn admin-btn--ghost admin-btn--xs" @click="openEditModal(customer)">
|
||||
ویرایش
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="filteredCustomers.length" class="compact-only admin-card-list">
|
||||
<article v-for="customer in filteredCustomers" :key="customer.id" class="admin-card-item">
|
||||
<div class="admin-card-item__header">
|
||||
<p class="admin-card-item__title">{{ customer.name }}</p>
|
||||
<ActiveSwitchCell
|
||||
:id="customer.id"
|
||||
v-model="customer.isActive"
|
||||
:entity-name="customer.name"
|
||||
entity-type="مشتری"
|
||||
/>
|
||||
</div>
|
||||
<div class="admin-card-item__meta">
|
||||
<span>{{ customer.phone }}</span>
|
||||
<span>{{ CUSTOMER_TYPE_LABELS[customer.customerType] }}</span>
|
||||
<span>{{ customer.companyName }}</span>
|
||||
<span>{{ customer.orderCount }} سفارش · {{ formatMeterage(customer.totalMeterage) }}</span>
|
||||
</div>
|
||||
<div class="admin-card-item__actions">
|
||||
<button type="button" class="admin-btn admin-btn--ghost admin-btn--xs" @click="openEditModal(customer)">
|
||||
ویرایش
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<BaseModal :open="isModalOpen" :title="modalTitle" size="lg" @close="closeModal">
|
||||
<form class="admin-form" @submit.prevent="submitCustomer">
|
||||
<p v-if="formError" class="admin-form__error" role="alert">{{ formError }}</p>
|
||||
|
||||
<div class="admin-form__field">
|
||||
<label for="customer-name">نام مشتری <span class="admin-form__required">*</span></label>
|
||||
<input id="customer-name" v-model="form.name" type="text" class="admin-input" />
|
||||
</div>
|
||||
|
||||
<div class="admin-form__row">
|
||||
<div class="admin-form__field">
|
||||
<label for="customer-phone">تلفن <span class="admin-form__required">*</span></label>
|
||||
<input id="customer-phone" v-model="form.phone" type="text" class="admin-input" />
|
||||
</div>
|
||||
<div class="admin-form__field">
|
||||
<label for="customer-national-id">شناسه ملی <span class="admin-form__required">*</span></label>
|
||||
<input id="customer-national-id" v-model="form.nationalId" type="text" class="admin-input" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-form__row">
|
||||
<div class="admin-form__field">
|
||||
<label for="customer-type">نوع مشتری</label>
|
||||
<select id="customer-type" v-model="form.customerType" class="admin-input">
|
||||
<option value="wholesale">عمدهفروش</option>
|
||||
<option value="retail">خردهفروش</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="admin-form__field">
|
||||
<label for="customer-company">شرکت مرتبط</label>
|
||||
<input id="customer-company" v-model="form.companyName" type="text" class="admin-input" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-form__field">
|
||||
<BaseSwitch v-model="form.isActive" label="وضعیت" />
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<template #footer>
|
||||
<button type="button" class="admin-btn admin-btn--outline" @click="closeModal">انصراف</button>
|
||||
<button type="button" class="admin-btn admin-btn--primary" :disabled="!isFormValid || saving" @click="submitCustomer">
|
||||
<span v-if="saving" class="admin-btn__spinner" aria-hidden="true" />
|
||||
{{ modalMode === 'edit' ? 'ذخیره تغییرات' : 'ثبت مشتری' }}
|
||||
</button>
|
||||
</template>
|
||||
</BaseModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.customers-page__search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
margin-bottom: 16px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.customers-page__search-input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 0.875rem;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.customers-page__table {
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import DesktopDashboard from '@/views/dashboard/components/DesktopDashboard.vue'
|
||||
import CompactDashboard from '@/views/dashboard/components/CompactDashboard.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DesktopDashboard class="desktop-only" />
|
||||
<CompactDashboard class="compact-only" />
|
||||
</template>
|
||||
@@ -0,0 +1,166 @@
|
||||
<script setup lang="ts">
|
||||
import AdminIcon from '@/components/icons/AdminIcon.vue'
|
||||
import StatusBadge from '@/components/ui/StatusBadge.vue'
|
||||
import SalesAreaChart from '@/components/charts/SalesAreaChart.vue'
|
||||
import { iconBg, iconColor, statCards, useDashboard } from '@/composables/useDashboard'
|
||||
import { formatCurrency, formatDateTime } from '@/utils/format'
|
||||
|
||||
const { checkDeadlines, dashboardStats, recentOrders, salesChartData, chartAxisLabels, goOrders } =
|
||||
useDashboard()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="compact-dashboard">
|
||||
<div class="dashboard__toolbar">
|
||||
<button type="button" class="admin-btn admin-btn--outline admin-btn--sm">
|
||||
<AdminIcon name="calendar" :size="16" />
|
||||
امروز
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="compact-stats-grid">
|
||||
<div v-for="card in statCards" :key="card.label" class="admin-stat-card">
|
||||
<div
|
||||
class="admin-stat-card__icon"
|
||||
:style="{ background: iconBg[card.tone], color: iconColor[card.tone] }"
|
||||
>
|
||||
<AdminIcon :name="card.icon" :size="20" />
|
||||
</div>
|
||||
<p class="admin-stat-card__label">{{ card.label }}</p>
|
||||
<p class="admin-stat-card__value">{{ card.value }}</p>
|
||||
<RouterLink :to="card.link" class="admin-stat-card__link">مشاهده</RouterLink>
|
||||
</div>
|
||||
|
||||
<div class="admin-stat-card compact-dashboard__sales">
|
||||
<p class="admin-stat-card__label">فروش امروز</p>
|
||||
<p class="admin-stat-card__value">{{ formatCurrency(dashboardStats.todaySales) }}</p>
|
||||
<span class="compact-dashboard__growth">
|
||||
+{{ dashboardStats.todaySalesGrowth }}% نسبت به دیروز
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="compact-section-grid">
|
||||
<section class="compact-section-card">
|
||||
<SalesAreaChart
|
||||
title="فروش ماه جاری"
|
||||
:subtitle="formatCurrency(dashboardStats.monthSales)"
|
||||
:data="salesChartData"
|
||||
:labels="chartAxisLabels"
|
||||
unit="میلیارد"
|
||||
:height="140"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section class="compact-section-card">
|
||||
<h2 class="compact-section-card__title">سفارشهای اخیر</h2>
|
||||
<div class="admin-card-list">
|
||||
<RouterLink
|
||||
v-for="order in recentOrders"
|
||||
:key="order.id"
|
||||
to="/orders"
|
||||
class="admin-card-item compact-dashboard__order-card"
|
||||
>
|
||||
<div class="admin-card-item__header">
|
||||
<p class="admin-card-item__title">{{ order.orderNumber }}</p>
|
||||
<StatusBadge :status="order.status" />
|
||||
</div>
|
||||
<div class="admin-card-item__meta">
|
||||
<span>{{ order.customerName }}</span>
|
||||
<span>{{ formatDateTime(order.createdAt) }}</span>
|
||||
</div>
|
||||
</RouterLink>
|
||||
</div>
|
||||
<button type="button" class="admin-link compact-dashboard__footer-link" @click="goOrders">
|
||||
مشاهده همه سفارشها
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section class="compact-section-card">
|
||||
<h2 class="compact-section-card__title">سررسید چکها</h2>
|
||||
<ul class="compact-dashboard__deadlines">
|
||||
<li v-for="item in checkDeadlines" :key="item.id">
|
||||
<span
|
||||
class="admin-badge"
|
||||
:class="{
|
||||
'admin-badge--danger': item.tone === 'danger',
|
||||
'admin-badge--warning': item.tone === 'warning',
|
||||
'admin-badge--neutral': item.tone === 'neutral',
|
||||
}"
|
||||
>
|
||||
{{ item.label }}
|
||||
</span>
|
||||
<span>{{ item.count }} فقره</span>
|
||||
<strong>{{ formatCurrency(item.amount) }}</strong>
|
||||
</li>
|
||||
</ul>
|
||||
<RouterLink to="/checks" class="admin-link compact-dashboard__footer-link">مشاهده همه</RouterLink>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dashboard__toolbar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.compact-dashboard__sales {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.compact-dashboard__growth {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-success);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.compact-dashboard__month-value {
|
||||
margin: 0 0 12px;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.dashboard__chart {
|
||||
width: 100%;
|
||||
height: 120px;
|
||||
}
|
||||
|
||||
.compact-dashboard__order-card {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.compact-dashboard__footer-link {
|
||||
display: inline-block;
|
||||
margin-top: 12px;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.compact-dashboard__deadlines {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.compact-dashboard__deadlines li {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.compact-dashboard__deadlines strong {
|
||||
margin-right: auto;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,245 @@
|
||||
<script setup lang="ts">
|
||||
import AdminIcon from '@/components/icons/AdminIcon.vue'
|
||||
import StatusBadge from '@/components/ui/StatusBadge.vue'
|
||||
import SalesAreaChart from '@/components/charts/SalesAreaChart.vue'
|
||||
import { iconBg, iconColor, statCards, useDashboard } from '@/composables/useDashboard'
|
||||
import { formatCurrency, formatDateTime } from '@/utils/format'
|
||||
|
||||
const { checkDeadlines, dashboardStats, recentOrders, salesChartData, chartAxisLabels, goOrders } =
|
||||
useDashboard()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="dashboard">
|
||||
<div class="dashboard__toolbar">
|
||||
<button type="button" class="admin-btn admin-btn--outline admin-btn--sm">
|
||||
<AdminIcon name="calendar" :size="16" />
|
||||
امروز
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="dashboard__stats">
|
||||
<div v-for="card in statCards" :key="card.label" class="admin-stat-card">
|
||||
<div
|
||||
class="admin-stat-card__icon"
|
||||
:style="{ background: iconBg[card.tone], color: iconColor[card.tone] }"
|
||||
>
|
||||
<AdminIcon :name="card.icon" :size="20" />
|
||||
</div>
|
||||
<p class="admin-stat-card__label">{{ card.label }}</p>
|
||||
<p class="admin-stat-card__value">{{ card.value }}</p>
|
||||
<RouterLink :to="card.link" class="admin-stat-card__link">مشاهده سفارشها</RouterLink>
|
||||
</div>
|
||||
|
||||
<div class="dashboard__sales-card admin-stat-card">
|
||||
<div class="dashboard__sales-header">
|
||||
<div>
|
||||
<p class="admin-stat-card__label">فروش امروز</p>
|
||||
<p class="admin-stat-card__value dashboard__sales-value">
|
||||
{{ formatCurrency(dashboardStats.todaySales) }}
|
||||
</p>
|
||||
<span class="dashboard__sales-growth">
|
||||
+{{ dashboardStats.todaySalesGrowth }}% نسبت به دیروز
|
||||
</span>
|
||||
</div>
|
||||
<AdminIcon name="calendar" :size="20" class="dashboard__sales-icon" />
|
||||
</div>
|
||||
<svg class="dashboard__sparkline" viewBox="0 0 120 32" preserveAspectRatio="none">
|
||||
<polyline
|
||||
fill="none"
|
||||
stroke="#159660"
|
||||
stroke-width="2"
|
||||
points="0,24 20,18 40,22 60,12 80,16 100,8 120,14"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dashboard__grid">
|
||||
<div class="dashboard__chart-card admin-card">
|
||||
<SalesAreaChart
|
||||
title="فروش ماه جاری"
|
||||
:subtitle="formatCurrency(dashboardStats.monthSales)"
|
||||
:data="salesChartData"
|
||||
:labels="chartAxisLabels"
|
||||
unit="میلیارد"
|
||||
:height="168"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="dashboard__orders-card admin-card">
|
||||
<div class="dashboard__card-header">
|
||||
<h2 class="dashboard__card-title">سفارشهای اخیر</h2>
|
||||
</div>
|
||||
<div class="admin-table-wrap">
|
||||
<table class="admin-table admin-table--compact">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>شماره سفارش</th>
|
||||
<th>مشتری</th>
|
||||
<th>شرکت</th>
|
||||
<th>وضعیت</th>
|
||||
<th>تاریخ ثبت</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="order in recentOrders" :key="order.id">
|
||||
<td>{{ order.orderNumber }}</td>
|
||||
<td>{{ order.customerName }}</td>
|
||||
<td>{{ order.companyName }}</td>
|
||||
<td><StatusBadge :status="order.status" /></td>
|
||||
<td>{{ formatDateTime(order.createdAt) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="dashboard__card-footer">
|
||||
<button type="button" class="admin-link" @click="goOrders">مشاهده همه سفارشها</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="dashboard__checks-card admin-card">
|
||||
<div class="dashboard__card-header">
|
||||
<h2 class="dashboard__card-title">سررسید چکها</h2>
|
||||
</div>
|
||||
<ul class="dashboard__deadline-list">
|
||||
<li v-for="item in checkDeadlines" :key="item.id" class="dashboard__deadline-item">
|
||||
<span
|
||||
class="admin-badge"
|
||||
:class="{
|
||||
'admin-badge--danger': item.tone === 'danger',
|
||||
'admin-badge--warning': item.tone === 'warning',
|
||||
'admin-badge--neutral': item.tone === 'neutral',
|
||||
}"
|
||||
>
|
||||
{{ item.label }}
|
||||
</span>
|
||||
<span class="dashboard__deadline-count">{{ item.count }} فقره</span>
|
||||
<span class="dashboard__deadline-amount">{{ formatCurrency(item.amount) }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<div class="dashboard__card-footer">
|
||||
<RouterLink to="/checks" class="admin-link">مشاهده همه</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dashboard__toolbar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.dashboard__stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.dashboard__sales-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.dashboard__sales-value {
|
||||
font-size: 1.25rem !important;
|
||||
}
|
||||
|
||||
.dashboard__sales-growth {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-success);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.dashboard__sales-icon {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.dashboard__sparkline {
|
||||
width: 100%;
|
||||
height: 32px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.dashboard__grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1.4fr 0.9fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.dashboard__chart-card,
|
||||
.dashboard__orders-card,
|
||||
.dashboard__checks-card {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.dashboard__chart-card :deep(.sales-chart__header) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.dashboard__card-header {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.dashboard__card-title {
|
||||
margin: 0;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.dashboard__card-value {
|
||||
margin: 4px 0 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.dashboard__chart {
|
||||
width: 100%;
|
||||
height: 140px;
|
||||
}
|
||||
|
||||
.dashboard__chart-axis {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-muted);
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.dashboard__card-footer {
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.dashboard__deadline-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.dashboard__deadline-item {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.dashboard__deadline-count {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.dashboard__deadline-amount {
|
||||
font-weight: 600;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,387 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import BaseModal from '@/components/ui/BaseModal.vue'
|
||||
import AdminIcon from '@/components/icons/AdminIcon.vue'
|
||||
import StatusBadge from '@/components/ui/StatusBadge.vue'
|
||||
import { orders } from '@/data/mockData'
|
||||
import { formatCurrency, formatNumber } from '@/utils/format'
|
||||
import type { OrderItem } from '@/types'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const order = computed(() => orders.find((o) => o.id === route.params.id) ?? orders[0]!)
|
||||
|
||||
const items = ref<OrderItem[]>(order.value.items?.map((i) => ({ ...i })) ?? [])
|
||||
|
||||
const shippingCost = ref(0)
|
||||
const customerNote = ref('')
|
||||
const showAddModal = ref(false)
|
||||
const formError = ref('')
|
||||
|
||||
const emptyForm = () => ({
|
||||
catalogCode: '',
|
||||
productName: '',
|
||||
color: '',
|
||||
requestedMeterage: 100,
|
||||
confirmedMeterage: 100,
|
||||
})
|
||||
|
||||
const form = reactive(emptyForm())
|
||||
|
||||
const isFormValid = computed(
|
||||
() =>
|
||||
form.catalogCode.trim().length > 0 &&
|
||||
form.productName.trim().length > 0 &&
|
||||
form.requestedMeterage > 0,
|
||||
)
|
||||
|
||||
function goBack() {
|
||||
router.push('/orders')
|
||||
}
|
||||
|
||||
function removeItem(id: string) {
|
||||
items.value = items.value.filter((i) => i.id !== id)
|
||||
}
|
||||
|
||||
function openAddModal() {
|
||||
Object.assign(form, emptyForm())
|
||||
formError.value = ''
|
||||
showAddModal.value = true
|
||||
}
|
||||
|
||||
function closeAddModal() {
|
||||
showAddModal.value = false
|
||||
formError.value = ''
|
||||
}
|
||||
|
||||
function submitAddProduct() {
|
||||
if (!isFormValid.value) {
|
||||
formError.value = 'کالیته، نام محصول و متراژ الزامی هستند.'
|
||||
return
|
||||
}
|
||||
|
||||
items.value.push({
|
||||
id: `oi-${crypto.randomUUID().slice(0, 8)}`,
|
||||
catalogCode: form.catalogCode.trim(),
|
||||
productName: form.productName.trim(),
|
||||
color: form.color.trim() || '—',
|
||||
requestedMeterage: form.requestedMeterage,
|
||||
confirmedMeterage: form.confirmedMeterage || form.requestedMeterage,
|
||||
supplyPrice: null,
|
||||
unitDiscount: 0,
|
||||
totalAmount: null,
|
||||
})
|
||||
|
||||
closeAddModal()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="pricing-page">
|
||||
<div class="admin-page-header">
|
||||
<div>
|
||||
<h1 class="admin-page-header__title">قیمتگذاری سفارش</h1>
|
||||
<p class="admin-page-header__breadcrumb">سفارشها / قیمتگذاری سفارش</p>
|
||||
</div>
|
||||
<div class="admin-action-group">
|
||||
<button type="button" class="admin-btn admin-btn--success admin-btn--sm">
|
||||
ارسال پیشفاکتور
|
||||
</button>
|
||||
<button type="button" class="admin-btn admin-btn--primary admin-btn--sm" @click="goBack">
|
||||
بازگشت
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pricing-page__summary admin-card">
|
||||
<div class="pricing-page__order-meta">
|
||||
<div>
|
||||
<strong class="pricing-page__order-id">{{ order.orderNumber }}</strong>
|
||||
<p class="pricing-page__customer">{{ order.customerName }}</p>
|
||||
<StatusBadge :status="order.status" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="admin-summary-grid">
|
||||
<div class="admin-summary-item">
|
||||
<p class="admin-summary-item__label">مجموع مبلغ</p>
|
||||
<p class="admin-summary-item__value">—</p>
|
||||
</div>
|
||||
<div class="admin-summary-item">
|
||||
<p class="admin-summary-item__label">تخفیف کل</p>
|
||||
<p class="admin-summary-item__value">۰ تومان</p>
|
||||
</div>
|
||||
<div class="admin-summary-item">
|
||||
<p class="admin-summary-item__label">هزینه حمل (٪)</p>
|
||||
<p class="admin-summary-item__value">۰٪</p>
|
||||
</div>
|
||||
<div class="admin-summary-item">
|
||||
<p class="admin-summary-item__label">مبلغ نهایی</p>
|
||||
<p class="admin-summary-item__value">— تومان</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pricing-page__table admin-card desktop-only">
|
||||
<div class="admin-table-wrap">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ردیف</th>
|
||||
<th>کالیته</th>
|
||||
<th>محصول</th>
|
||||
<th>رنگ / نوع</th>
|
||||
<th>متراژ درخواستی</th>
|
||||
<th>متراژ تأیید شده</th>
|
||||
<th>قیمت تأمینی (واحد)</th>
|
||||
<th>تخفیف (واحد)</th>
|
||||
<th>مبلغ کل (تومان)</th>
|
||||
<th>عملیات</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(item, index) in items" :key="item.id">
|
||||
<td>{{ index + 1 }}</td>
|
||||
<td>{{ item.catalogCode }}</td>
|
||||
<td>{{ item.productName }}</td>
|
||||
<td>{{ item.color }}</td>
|
||||
<td>{{ formatNumber(item.requestedMeterage) }}</td>
|
||||
<td>
|
||||
<input
|
||||
v-model.number="item.confirmedMeterage"
|
||||
type="number"
|
||||
class="admin-input admin-input--sm"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
:value="item.supplyPrice ?? ''"
|
||||
type="text"
|
||||
class="admin-input admin-input--sm"
|
||||
placeholder="—"
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
v-model.number="item.unitDiscount"
|
||||
type="number"
|
||||
class="admin-input admin-input--sm"
|
||||
/>
|
||||
</td>
|
||||
<td>—</td>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
class="admin-btn admin-btn--outline-danger admin-btn--xs"
|
||||
@click="removeItem(item.id)"
|
||||
>
|
||||
حذف
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<button type="button" class="admin-btn admin-btn--ghost pricing-page__add-btn" @click="openAddModal">
|
||||
<AdminIcon name="products" :size="16" />
|
||||
افزودن محصول
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="compact-only admin-card-list pricing-page__compact-items">
|
||||
<article v-for="(item, index) in items" :key="item.id" class="admin-card-item">
|
||||
<div class="admin-card-item__header">
|
||||
<p class="admin-card-item__title">{{ item.productName }}</p>
|
||||
<span class="pricing-page__item-index">#{{ index + 1 }}</span>
|
||||
</div>
|
||||
<div class="admin-card-item__meta">
|
||||
<span>کالیته {{ item.catalogCode }}</span>
|
||||
<span>{{ item.color }}</span>
|
||||
<span>درخواست: {{ formatNumber(item.requestedMeterage) }} متر</span>
|
||||
</div>
|
||||
<div class="pricing-page__compact-fields">
|
||||
<label>
|
||||
متراژ تأیید شده
|
||||
<input v-model.number="item.confirmedMeterage" type="number" class="admin-input" />
|
||||
</label>
|
||||
<label>
|
||||
تخفیف واحد
|
||||
<input v-model.number="item.unitDiscount" type="number" class="admin-input" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="admin-card-item__actions">
|
||||
<button type="button" class="admin-btn admin-btn--outline-danger admin-btn--xs" @click="removeItem(item.id)">
|
||||
حذف
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
<button type="button" class="admin-btn admin-btn--ghost" @click="openAddModal">
|
||||
<AdminIcon name="products" :size="16" />
|
||||
افزودن محصول
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="admin-form-grid">
|
||||
<div class="admin-form-field">
|
||||
<label>اعمال تخفیف روی فاکتور</label>
|
||||
<select class="admin-input">
|
||||
<option>بدون اعمال</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="admin-form-field">
|
||||
<label>هزینه حمل (تومان)</label>
|
||||
<input v-model.number="shippingCost" type="number" class="admin-input" />
|
||||
</div>
|
||||
<div class="admin-form-field">
|
||||
<label>توضیحات برای مشتری</label>
|
||||
<textarea v-model="customerNote" placeholder="توضیحات خود را وارد کنید..." />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-footer-actions">
|
||||
<button type="button" class="admin-btn admin-btn--success">ارسال پیشفاکتور</button>
|
||||
<button type="button" class="admin-btn admin-btn--outline-warning">نیاز به اصلاح</button>
|
||||
<button type="button" class="admin-btn admin-btn--outline-danger">رد سفارش</button>
|
||||
</div>
|
||||
|
||||
<BaseModal :open="showAddModal" title="افزودن محصول" size="lg" @close="closeAddModal">
|
||||
<form class="admin-form" @submit.prevent="submitAddProduct">
|
||||
<p v-if="formError" class="admin-form__error" role="alert">{{ formError }}</p>
|
||||
|
||||
<div class="admin-form__row">
|
||||
<div class="admin-form__field">
|
||||
<label for="catalog-code">کالیته <span class="admin-form__required">*</span></label>
|
||||
<input
|
||||
id="catalog-code"
|
||||
v-model="form.catalogCode"
|
||||
type="text"
|
||||
class="admin-input"
|
||||
placeholder="377"
|
||||
/>
|
||||
</div>
|
||||
<div class="admin-form__field">
|
||||
<label for="product-name">محصول <span class="admin-form__required">*</span></label>
|
||||
<input
|
||||
id="product-name"
|
||||
v-model="form.productName"
|
||||
type="text"
|
||||
class="admin-input"
|
||||
placeholder="پارچه آبی"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-form__field">
|
||||
<label for="product-color">رنگ / نوع</label>
|
||||
<input
|
||||
id="product-color"
|
||||
v-model="form.color"
|
||||
type="text"
|
||||
class="admin-input"
|
||||
placeholder="آبی"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="admin-form__row">
|
||||
<div class="admin-form__field">
|
||||
<label for="requested-meterage">
|
||||
متراژ درخواستی <span class="admin-form__required">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="requested-meterage"
|
||||
v-model.number="form.requestedMeterage"
|
||||
type="number"
|
||||
min="1"
|
||||
class="admin-input"
|
||||
/>
|
||||
</div>
|
||||
<div class="admin-form__field">
|
||||
<label for="confirmed-meterage">متراژ تأیید شده</label>
|
||||
<input
|
||||
id="confirmed-meterage"
|
||||
v-model.number="form.confirmedMeterage"
|
||||
type="number"
|
||||
min="0"
|
||||
class="admin-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<template #footer>
|
||||
<button type="button" class="admin-btn admin-btn--outline" @click="closeAddModal">
|
||||
انصراف
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="admin-btn admin-btn--primary"
|
||||
:disabled="!isFormValid"
|
||||
@click="submitAddProduct"
|
||||
>
|
||||
افزودن به سفارش
|
||||
</button>
|
||||
</template>
|
||||
</BaseModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.pricing-page__summary {
|
||||
padding: 20px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.pricing-page__order-meta {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.pricing-page__order-id {
|
||||
font-size: 1rem;
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.pricing-page__customer {
|
||||
margin: 0 0 8px;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.pricing-page__table {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.pricing-page__add-btn {
|
||||
margin: 12px 16px 16px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.pricing-page__compact-items {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.pricing-page__item-index {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.pricing-page__compact-fields {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.pricing-page__compact-fields label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { provideOrdersPage } from '@/composables/useOrdersPage'
|
||||
import DesktopOrdersPage from '@/views/orders/components/DesktopOrdersPage.vue'
|
||||
import CompactOrdersPage from '@/views/orders/components/CompactOrdersPage.vue'
|
||||
|
||||
provideOrdersPage()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DesktopOrdersPage class="desktop-only" />
|
||||
<CompactOrdersPage class="compact-only" />
|
||||
</template>
|
||||
@@ -0,0 +1,159 @@
|
||||
<script setup lang="ts">
|
||||
import AdminIcon from '@/components/icons/AdminIcon.vue'
|
||||
import StatusBadge from '@/components/ui/StatusBadge.vue'
|
||||
import OrderDetailContent from '@/views/orders/components/OrderDetailContent.vue'
|
||||
import OrderAddModal from '@/views/orders/components/OrderAddModal.vue'
|
||||
import { useOrdersPageContext } from '@/composables/useOrdersPage'
|
||||
import { ORDER_TABS } from '@/utils/orderStatus'
|
||||
import { formatCurrency, formatDateTime, formatMeterage } from '@/utils/format'
|
||||
|
||||
const {
|
||||
activeTab,
|
||||
selectedOrderId,
|
||||
searchQuery,
|
||||
filteredOrders,
|
||||
selectedOrder,
|
||||
showDetailPanel,
|
||||
selectOrder,
|
||||
closeDetailPanel,
|
||||
settlementLabel,
|
||||
goPricing,
|
||||
goChecks,
|
||||
openAddModal,
|
||||
} = useOrdersPageContext()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="compact-orders">
|
||||
<div class="compact-page-header">
|
||||
<div class="compact-page-header__row">
|
||||
<div>
|
||||
<h2 class="compact-page-header__title">سفارشها</h2>
|
||||
<p class="compact-page-header__breadcrumb">داشبورد / سفارشها</p>
|
||||
</div>
|
||||
<div class="compact-page-header__actions">
|
||||
<button type="button" class="admin-btn admin-btn--outline admin-btn--sm">
|
||||
<AdminIcon name="filter" :size="16" />
|
||||
فیلتر
|
||||
</button>
|
||||
<button type="button" class="admin-btn admin-btn--primary admin-btn--sm" @click="openAddModal">
|
||||
سفارش جدید
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="orders-page__search admin-card">
|
||||
<AdminIcon name="search" :size="18" />
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="search"
|
||||
placeholder="جستجو در شماره سفارش، مشتری..."
|
||||
class="orders-page__search-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="admin-tabs compact-orders__tabs">
|
||||
<button
|
||||
v-for="tab in ORDER_TABS"
|
||||
:key="tab.key"
|
||||
type="button"
|
||||
class="admin-tab"
|
||||
:class="{ active: activeTab === tab.key }"
|
||||
@click="activeTab = tab.key"
|
||||
>
|
||||
{{ tab.label }}
|
||||
<template v-if="'count' in tab && tab.count"> ({{ tab.count }})</template>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="admin-card-list">
|
||||
<button
|
||||
v-for="order in filteredOrders"
|
||||
:key="order.id"
|
||||
type="button"
|
||||
class="admin-card-item"
|
||||
:class="{ selected: selectedOrderId === order.id }"
|
||||
@click="selectOrder(order, true)"
|
||||
>
|
||||
<div class="admin-card-item__header">
|
||||
<p class="admin-card-item__title">{{ order.orderNumber }}</p>
|
||||
<StatusBadge :status="order.status" />
|
||||
</div>
|
||||
<div class="admin-card-item__meta">
|
||||
<span><strong>مشتری:</strong> {{ order.customerName }}</span>
|
||||
<span><strong>متراژ:</strong> {{ formatMeterage(order.meterage) }}</span>
|
||||
<span><strong>مبلغ:</strong> {{ order.totalAmount ? formatCurrency(order.totalAmount) : '—' }}</span>
|
||||
<span>{{ formatDateTime(order.createdAt) }}</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="admin-detail-panel" :class="{ open: showDetailPanel && selectedOrder }">
|
||||
<div v-if="selectedOrder" class="admin-detail-panel__inner">
|
||||
<div class="admin-detail-panel__header">
|
||||
<button
|
||||
type="button"
|
||||
class="admin-detail-panel__back"
|
||||
aria-label="بازگشت به لیست"
|
||||
@click="closeDetailPanel"
|
||||
>
|
||||
<AdminIcon name="chevron-left" :size="20" />
|
||||
</button>
|
||||
<h2 class="admin-detail-panel__title">جزئیات سفارش</h2>
|
||||
</div>
|
||||
<div class="admin-detail-panel__body">
|
||||
<OrderDetailContent
|
||||
:order="selectedOrder"
|
||||
:settlement-label="settlementLabel"
|
||||
@pricing="goPricing(selectedOrder)"
|
||||
@checks="goChecks(selectedOrder)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<OrderAddModal />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.compact-orders__tabs {
|
||||
margin-bottom: 16px;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 4px 8px 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.orders-page__search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
margin-bottom: 12px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.orders-page__search-input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 0.875rem;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.admin-card-item {
|
||||
cursor: pointer;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.admin-detail-panel__inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: var(--color-bg);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,241 @@
|
||||
<script setup lang="ts">
|
||||
import AdminIcon from '@/components/icons/AdminIcon.vue'
|
||||
import StatusBadge from '@/components/ui/StatusBadge.vue'
|
||||
import BaseModal from '@/components/ui/BaseModal.vue'
|
||||
import OrderDetailContent from '@/views/orders/components/OrderDetailContent.vue'
|
||||
import OrderAddModal from '@/views/orders/components/OrderAddModal.vue'
|
||||
import { useOrdersPageContext } from '@/composables/useOrdersPage'
|
||||
import { ORDER_TABS } from '@/utils/orderStatus'
|
||||
import { formatCurrency, formatDateTime, formatMeterage } from '@/utils/format'
|
||||
|
||||
const {
|
||||
activeTab,
|
||||
selectedOrderId,
|
||||
searchQuery,
|
||||
filteredOrders,
|
||||
selectedOrder,
|
||||
showDetailModal,
|
||||
openOrderDetail,
|
||||
closeDetailModal,
|
||||
settlementLabel,
|
||||
goPricing,
|
||||
goChecks,
|
||||
openAddModal,
|
||||
} = useOrdersPageContext()
|
||||
|
||||
function handlePricing() {
|
||||
if (!selectedOrder.value) return
|
||||
closeDetailModal()
|
||||
goPricing(selectedOrder.value)
|
||||
}
|
||||
|
||||
function handleChecks() {
|
||||
if (!selectedOrder.value) return
|
||||
closeDetailModal()
|
||||
goChecks(selectedOrder.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="orders-page">
|
||||
<div class="orders-page__list-panel admin-card">
|
||||
<div class="orders-page__list-header">
|
||||
<div>
|
||||
<h2 class="orders-page__list-title">سفارشها</h2>
|
||||
<p class="orders-page__breadcrumb">داشبورد / سفارشها</p>
|
||||
</div>
|
||||
<div class="orders-page__list-actions">
|
||||
<button type="button" class="admin-btn admin-btn--outline admin-btn--sm">
|
||||
<AdminIcon name="filter" :size="16" />
|
||||
فیلتر
|
||||
</button>
|
||||
<button type="button" class="admin-btn admin-btn--primary admin-btn--sm" @click="openAddModal">
|
||||
سفارش جدید
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="orders-page__search">
|
||||
<AdminIcon name="search" :size="18" />
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="search"
|
||||
placeholder="جستجو در شماره سفارش، مشتری..."
|
||||
class="orders-page__search-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="admin-tabs">
|
||||
<button
|
||||
v-for="tab in ORDER_TABS"
|
||||
:key="tab.key"
|
||||
type="button"
|
||||
class="admin-tab"
|
||||
:class="{ active: activeTab === tab.key }"
|
||||
@click="activeTab = tab.key"
|
||||
>
|
||||
{{ tab.label }}
|
||||
<template v-if="'count' in tab && tab.count"> ({{ tab.count }})</template>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="admin-table-wrap">
|
||||
<table class="admin-table admin-table--compact">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>شماره سفارش</th>
|
||||
<th>مشتری</th>
|
||||
<th>روش تسویه</th>
|
||||
<th>مبلغ کل</th>
|
||||
<th>متراژ</th>
|
||||
<th>وضعیت</th>
|
||||
<th>تاریخ ثبت</th>
|
||||
<th>عملیات</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="order in filteredOrders"
|
||||
:key="order.id"
|
||||
:class="{ 'is-selected': selectedOrderId === order.id }"
|
||||
@click="openOrderDetail(order)"
|
||||
>
|
||||
<td>{{ order.orderNumber }}</td>
|
||||
<td>{{ order.customerName }}</td>
|
||||
<td>{{ settlementLabel(order.settlementMethod) }}</td>
|
||||
<td>{{ order.totalAmount ? formatCurrency(order.totalAmount) : '—' }}</td>
|
||||
<td>{{ formatMeterage(order.meterage) }}</td>
|
||||
<td><StatusBadge :status="order.status" /></td>
|
||||
<td>{{ formatDateTime(order.createdAt) }}</td>
|
||||
<td>
|
||||
<button type="button" class="orders-page__more-btn" @click.stop>
|
||||
<AdminIcon name="more" :size="18" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="admin-pagination">
|
||||
<span>نمایش ۱ تا ۷ از ۱۰۳ سفارش</span>
|
||||
<div class="admin-pagination__pages">
|
||||
<button type="button" class="admin-pagination__page active">۱</button>
|
||||
<button type="button" class="admin-pagination__page">۲</button>
|
||||
<button type="button" class="admin-pagination__page">۳</button>
|
||||
<span>...</span>
|
||||
<button type="button" class="admin-pagination__page">۱۲</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BaseModal
|
||||
:open="showDetailModal && !!selectedOrder"
|
||||
title="جزئیات سفارش"
|
||||
size="xl"
|
||||
@close="closeDetailModal"
|
||||
>
|
||||
<template v-if="selectedOrder">
|
||||
<p class="orders-page__breadcrumb orders-page__modal-breadcrumb">
|
||||
سفارشها / جزئیات سفارش
|
||||
</p>
|
||||
<OrderDetailContent
|
||||
:order="selectedOrder"
|
||||
layout="desktop"
|
||||
:settlement-label="settlementLabel"
|
||||
@pricing="handlePricing"
|
||||
@checks="handleChecks"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<button type="button" class="admin-btn admin-btn--outline admin-btn--sm">
|
||||
<AdminIcon name="sms" :size="16" />
|
||||
ارسال پیامک
|
||||
</button>
|
||||
<button type="button" class="admin-btn admin-btn--outline admin-btn--sm">
|
||||
<AdminIcon name="print" :size="16" />
|
||||
چاپ
|
||||
</button>
|
||||
</template>
|
||||
</BaseModal>
|
||||
|
||||
<OrderAddModal />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.orders-page {
|
||||
min-height: calc(100dvh - var(--header-height) - 48px);
|
||||
}
|
||||
|
||||
.orders-page__list-panel {
|
||||
padding: 20px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.orders-page__list-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.orders-page__list-title {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.orders-page__breadcrumb {
|
||||
margin: 4px 0 0;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.orders-page__list-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.orders-page__search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-border-light);
|
||||
margin-bottom: 16px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.orders-page__search-input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 0.875rem;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.orders-page__more-btn {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
padding: 4px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.admin-table tbody tr {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.admin-table tbody tr.is-selected {
|
||||
background: var(--color-primary-soft);
|
||||
}
|
||||
|
||||
.orders-page__modal-breadcrumb {
|
||||
margin: 0 0 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,88 @@
|
||||
<script setup lang="ts">
|
||||
import BaseModal from '@/components/ui/BaseModal.vue'
|
||||
import { useOrdersPageContext } from '@/composables/useOrdersPage'
|
||||
|
||||
const {
|
||||
showAddModal,
|
||||
formError,
|
||||
form,
|
||||
isFormValid,
|
||||
companies,
|
||||
closeAddModal,
|
||||
submitAddOrder,
|
||||
} = useOrdersPageContext()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseModal :open="showAddModal" title="سفارش جدید" size="lg" @close="closeAddModal">
|
||||
<form class="admin-form" @submit.prevent="submitAddOrder">
|
||||
<p v-if="formError" class="admin-form__error" role="alert">{{ formError }}</p>
|
||||
|
||||
<div class="admin-form__field">
|
||||
<label for="order-customer">نام مشتری <span class="admin-form__required">*</span></label>
|
||||
<input
|
||||
id="order-customer"
|
||||
v-model="form.customerName"
|
||||
type="text"
|
||||
class="admin-input"
|
||||
placeholder="مثال: پارچهسرای نیکو"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="admin-form__row">
|
||||
<div class="admin-form__field">
|
||||
<label for="order-company">شرکت <span class="admin-form__required">*</span></label>
|
||||
<select id="order-company" v-model="form.companyId" class="admin-input">
|
||||
<option v-for="company in companies" :key="company.id" :value="company.id">
|
||||
{{ company.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="admin-form__field">
|
||||
<label for="order-settlement">روش تسویه</label>
|
||||
<select id="order-settlement" v-model="form.settlementMethod" class="admin-input">
|
||||
<option value="check">چکی</option>
|
||||
<option value="cash">نقدی</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-form__row">
|
||||
<div class="admin-form__field">
|
||||
<label for="order-meterage">متراژ (متر)</label>
|
||||
<input
|
||||
id="order-meterage"
|
||||
v-model.number="form.meterage"
|
||||
type="number"
|
||||
min="1"
|
||||
class="admin-input"
|
||||
/>
|
||||
</div>
|
||||
<div class="admin-form__field">
|
||||
<label for="order-phone">تلفن مشتری</label>
|
||||
<input
|
||||
id="order-phone"
|
||||
v-model="form.customerPhone"
|
||||
type="tel"
|
||||
class="admin-input"
|
||||
placeholder="۰۹۱۲۱۲۳۴۵۶۷"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<template #footer>
|
||||
<button type="button" class="admin-btn admin-btn--outline" @click="closeAddModal">
|
||||
انصراف
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="admin-btn admin-btn--primary"
|
||||
:disabled="!isFormValid"
|
||||
@click="submitAddOrder"
|
||||
>
|
||||
ثبت سفارش
|
||||
</button>
|
||||
</template>
|
||||
</BaseModal>
|
||||
</template>
|
||||
@@ -0,0 +1,183 @@
|
||||
<script setup lang="ts">
|
||||
import AdminIcon from '@/components/icons/AdminIcon.vue'
|
||||
import StatusBadge from '@/components/ui/StatusBadge.vue'
|
||||
import { formatMeterage } from '@/utils/format'
|
||||
import type { Order } from '@/types'
|
||||
|
||||
defineProps<{
|
||||
order: Order
|
||||
settlementLabel: (method: string) => string
|
||||
layout?: 'desktop' | 'compact'
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
pricing: []
|
||||
checks: []
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="order-detail">
|
||||
<div class="order-detail__id">
|
||||
<strong>{{ order.orderNumber }}</strong>
|
||||
<StatusBadge :status="order.status" />
|
||||
</div>
|
||||
|
||||
<div class="order-detail__section">
|
||||
<h3 class="order-detail__section-title">اطلاعات مشتری</h3>
|
||||
<div class="admin-info-grid">
|
||||
<div class="admin-info-row">
|
||||
<AdminIcon name="building" :size="16" />
|
||||
<span class="admin-info-row__label">شرکت:</span>
|
||||
<span class="admin-info-row__value">{{ order.customerName }}</span>
|
||||
</div>
|
||||
<div class="admin-info-row">
|
||||
<AdminIcon name="phone" :size="16" />
|
||||
<span class="admin-info-row__label">تلفن:</span>
|
||||
<span class="admin-info-row__value">{{ order.customerPhone ?? '—' }}</span>
|
||||
</div>
|
||||
<div class="admin-info-row">
|
||||
<AdminIcon name="id-card" :size="16" />
|
||||
<span class="admin-info-row__label">شناسه ملی:</span>
|
||||
<span class="admin-info-row__value">{{ order.customerNationalId ?? '—' }}</span>
|
||||
</div>
|
||||
<div class="admin-info-row">
|
||||
<AdminIcon name="user" :size="16" />
|
||||
<span class="admin-info-row__label">نوع مشتری:</span>
|
||||
<span class="admin-info-row__value">{{ order.customerType ?? '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="order-detail__section">
|
||||
<h3 class="order-detail__section-title">اطلاعات سفارش</h3>
|
||||
<div class="admin-info-grid">
|
||||
<div class="admin-info-row">
|
||||
<span class="admin-info-row__label">شرکت:</span>
|
||||
<span class="admin-info-row__value">{{ order.companyName }}</span>
|
||||
</div>
|
||||
<div class="admin-info-row">
|
||||
<span class="admin-info-row__label">روش تسویه:</span>
|
||||
<span class="admin-info-row__value">
|
||||
{{ settlementLabel(order.settlementMethod) }} — مختص
|
||||
</span>
|
||||
</div>
|
||||
<div class="admin-info-row">
|
||||
<span class="admin-info-row__label">متراژ کل:</span>
|
||||
<span class="admin-info-row__value">{{ formatMeterage(order.meterage) }}</span>
|
||||
</div>
|
||||
<div class="admin-info-row">
|
||||
<span class="admin-info-row__label">مبلغ کل:</span>
|
||||
<span class="admin-info-row__value">پس از قیمتگذاری</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="order.items?.length" class="order-detail__section">
|
||||
<h3 class="order-detail__section-title">محصولات سفارش</h3>
|
||||
<div v-if="layout === 'desktop'" class="admin-table-wrap">
|
||||
<table class="admin-table admin-table--compact">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>کالیته</th>
|
||||
<th>محصول</th>
|
||||
<th>رنگ / نوع</th>
|
||||
<th>متراژ</th>
|
||||
<th>قیمت تأیید شده</th>
|
||||
<th>مبلغ کل</th>
|
||||
<th>وضعیت</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in order.items" :key="item.id">
|
||||
<td>{{ item.catalogCode }}</td>
|
||||
<td>{{ item.productName }}</td>
|
||||
<td>{{ item.color }}</td>
|
||||
<td>{{ item.confirmedMeterage ?? item.requestedMeterage }}</td>
|
||||
<td>—</td>
|
||||
<td>—</td>
|
||||
<td><StatusBadge status="pending_review" /></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<ul v-else class="order-detail__items">
|
||||
<li v-for="item in order.items" :key="item.id" class="order-detail__item">
|
||||
<strong>{{ item.productName }}</strong>
|
||||
<span>{{ item.catalogCode }} · {{ item.color }}</span>
|
||||
<span>{{ item.confirmedMeterage ?? item.requestedMeterage }} متر</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="order-detail__footer">
|
||||
<button type="button" class="admin-btn admin-btn--primary admin-btn--sm" @click="$emit('pricing')">
|
||||
قیمتگذاری سفارش
|
||||
</button>
|
||||
<button
|
||||
v-if="order.settlementMethod === 'check'"
|
||||
type="button"
|
||||
class="admin-btn admin-btn--outline admin-btn--sm"
|
||||
@click="$emit('checks')"
|
||||
>
|
||||
مدیریت چکها
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.order-detail__id {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
font-size: 0.9375rem;
|
||||
}
|
||||
|
||||
.order-detail__section {
|
||||
margin-bottom: 20px;
|
||||
padding-bottom: 16px;
|
||||
border-bottom: 1px solid var(--color-border-light);
|
||||
}
|
||||
|
||||
.order-detail__section-title {
|
||||
margin: 0 0 12px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.order-detail__items {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.order-detail__item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 10px 12px;
|
||||
background: var(--color-border-light);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.order-detail__item strong {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.order-detail__item span {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.order-detail__footer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,358 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import BaseModal from '@/components/ui/BaseModal.vue'
|
||||
import BaseSwitch from '@/components/ui/BaseSwitch.vue'
|
||||
import BaseEmptyState from '@/components/ui/BaseEmptyState.vue'
|
||||
import ActiveSwitchCell from '@/components/ui/ActiveSwitchCell.vue'
|
||||
import AdminIcon from '@/components/icons/AdminIcon.vue'
|
||||
import { catalogs, products as initialProducts } from '@/data/mockData'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { formatCurrency, formatNumber } from '@/utils/format'
|
||||
import type { Product } from '@/types'
|
||||
|
||||
const productList = ref<Product[]>([...initialProducts])
|
||||
const searchQuery = ref('')
|
||||
const catalogFilter = ref('all')
|
||||
const modalMode = ref<'add' | 'edit' | null>(null)
|
||||
const editingId = ref<string | null>(null)
|
||||
const formError = ref('')
|
||||
const toast = useToast()
|
||||
|
||||
const emptyForm = () => ({
|
||||
catalogId: catalogs[0]?.id ?? '',
|
||||
name: '',
|
||||
color: '',
|
||||
productCode: '',
|
||||
minMeterage: 100,
|
||||
orderStep: 50,
|
||||
price: null as number | null,
|
||||
internalStock: 0,
|
||||
isActive: true,
|
||||
})
|
||||
|
||||
const form = reactive(emptyForm())
|
||||
|
||||
const isModalOpen = computed(() => modalMode.value !== null)
|
||||
const modalTitle = computed(() => (modalMode.value === 'edit' ? 'ویرایش محصول' : 'افزودن محصول'))
|
||||
const isFormValid = computed(() => form.name.trim() && form.catalogId && form.minMeterage > 0)
|
||||
const hasActiveFilters = computed(
|
||||
() => searchQuery.value.trim().length > 0 || catalogFilter.value !== 'all',
|
||||
)
|
||||
|
||||
const filteredProducts = computed(() => {
|
||||
let list = productList.value
|
||||
if (catalogFilter.value !== 'all') {
|
||||
list = list.filter((p) => p.catalogId === catalogFilter.value)
|
||||
}
|
||||
const q = searchQuery.value.trim()
|
||||
if (q) {
|
||||
list = list.filter(
|
||||
(p) =>
|
||||
p.name.includes(q) ||
|
||||
p.color.includes(q) ||
|
||||
p.productCode.includes(q) ||
|
||||
p.catalogCode.includes(q),
|
||||
)
|
||||
}
|
||||
return list
|
||||
})
|
||||
|
||||
function catalogMeta(catalogId: string) {
|
||||
return catalogs.find((c) => c.id === catalogId)
|
||||
}
|
||||
|
||||
function openAddModal() {
|
||||
modalMode.value = 'add'
|
||||
editingId.value = null
|
||||
Object.assign(form, emptyForm())
|
||||
formError.value = ''
|
||||
}
|
||||
|
||||
function openEditModal(product: Product) {
|
||||
modalMode.value = 'edit'
|
||||
editingId.value = product.id
|
||||
form.catalogId = product.catalogId
|
||||
form.name = product.name
|
||||
form.color = product.color
|
||||
form.productCode = product.productCode
|
||||
form.minMeterage = product.minMeterage
|
||||
form.orderStep = product.orderStep
|
||||
form.price = product.price
|
||||
form.internalStock = product.internalStock
|
||||
form.isActive = product.isActive
|
||||
formError.value = ''
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
modalMode.value = null
|
||||
editingId.value = null
|
||||
formError.value = ''
|
||||
}
|
||||
|
||||
function submitProduct() {
|
||||
if (!isFormValid.value) {
|
||||
formError.value = 'نام محصول، کالیته و حداقل متراژ الزامی هستند.'
|
||||
return
|
||||
}
|
||||
|
||||
const cat = catalogMeta(form.catalogId)
|
||||
const payload = {
|
||||
catalogId: form.catalogId,
|
||||
catalogCode: cat?.code ?? '—',
|
||||
companyName: cat?.companyName ?? '—',
|
||||
name: form.name.trim(),
|
||||
color: form.color.trim() || '—',
|
||||
productCode: form.productCode.trim() || `${cat?.code ?? 'X'}-NEW`,
|
||||
minMeterage: form.minMeterage,
|
||||
orderStep: form.orderStep,
|
||||
price: form.price,
|
||||
internalStock: form.internalStock,
|
||||
isActive: form.isActive,
|
||||
}
|
||||
|
||||
if (modalMode.value === 'edit' && editingId.value) {
|
||||
const index = productList.value.findIndex((p) => p.id === editingId.value)
|
||||
if (index !== -1) {
|
||||
productList.value[index] = { ...productList.value[index]!, ...payload }
|
||||
}
|
||||
toast.success('اطلاعات محصول بهروزرسانی شد.')
|
||||
} else {
|
||||
productList.value.push({
|
||||
id: `prod-${crypto.randomUUID().slice(0, 8)}`,
|
||||
...payload,
|
||||
})
|
||||
toast.success('محصول جدید ثبت شد.')
|
||||
}
|
||||
|
||||
closeModal()
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
searchQuery.value = ''
|
||||
catalogFilter.value = 'all'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="products-page">
|
||||
<div class="admin-page-header">
|
||||
<div>
|
||||
<h1 class="admin-page-header__title">محصولات</h1>
|
||||
<p class="admin-page-header__breadcrumb">مدیریت / محصولات</p>
|
||||
</div>
|
||||
<button type="button" class="admin-btn admin-btn--primary" @click="openAddModal">
|
||||
افزودن محصول
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="products-page__filters admin-card">
|
||||
<div class="products-page__search">
|
||||
<AdminIcon name="search" :size="18" />
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="search"
|
||||
class="products-page__search-input"
|
||||
placeholder="جستجو در نام، رنگ، کد..."
|
||||
/>
|
||||
</div>
|
||||
<select v-model="catalogFilter" class="admin-input products-page__catalog-filter">
|
||||
<option value="all">همه کالیتهها</option>
|
||||
<option v-for="catalog in catalogs" :key="catalog.id" :value="catalog.id">
|
||||
{{ catalog.code }} — {{ catalog.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<BaseEmptyState
|
||||
v-if="!filteredProducts.length"
|
||||
class="admin-card"
|
||||
icon="products"
|
||||
:title="hasActiveFilters ? 'نتیجهای یافت نشد' : 'محصولی ثبت نشده'"
|
||||
:description="
|
||||
hasActiveFilters
|
||||
? 'عبارت جستجو را تغییر دهید یا فیلترها را پاک کنید.'
|
||||
: 'برای شروع، اولین محصول را ثبت کنید.'
|
||||
"
|
||||
>
|
||||
<button v-if="hasActiveFilters" type="button" class="admin-btn admin-btn--outline admin-btn--sm" @click="clearFilters">
|
||||
پاک کردن فیلترها
|
||||
</button>
|
||||
<button v-else type="button" class="admin-btn admin-btn--primary admin-btn--sm" @click="openAddModal">
|
||||
افزودن محصول
|
||||
</button>
|
||||
</BaseEmptyState>
|
||||
|
||||
<div v-else class="products-page__table admin-card desktop-only">
|
||||
<div class="admin-table-wrap">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>کد محصول</th>
|
||||
<th>نام</th>
|
||||
<th>رنگ</th>
|
||||
<th>کالیته</th>
|
||||
<th>شرکت</th>
|
||||
<th class="numeric">حداقل متراژ</th>
|
||||
<th class="numeric">ضریب</th>
|
||||
<th class="numeric">قیمت</th>
|
||||
<th class="numeric">موجودی</th>
|
||||
<th class="status">وضعیت</th>
|
||||
<th class="actions">عملیات</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="product in filteredProducts" :key="product.id">
|
||||
<td>{{ product.productCode }}</td>
|
||||
<td>{{ product.name }}</td>
|
||||
<td>{{ product.color }}</td>
|
||||
<td>{{ product.catalogCode }}</td>
|
||||
<td>{{ product.companyName }}</td>
|
||||
<td class="numeric">{{ formatNumber(product.minMeterage) }} متر</td>
|
||||
<td class="numeric">{{ formatNumber(product.orderStep) }}</td>
|
||||
<td class="numeric">{{ product.price ? formatCurrency(product.price) : '—' }}</td>
|
||||
<td class="numeric">{{ formatNumber(product.internalStock) }} متر</td>
|
||||
<td class="status">
|
||||
<ActiveSwitchCell
|
||||
:id="product.id"
|
||||
v-model="product.isActive"
|
||||
:entity-name="product.name"
|
||||
entity-type="محصول"
|
||||
/>
|
||||
</td>
|
||||
<td class="actions">
|
||||
<button type="button" class="admin-btn admin-btn--ghost admin-btn--xs" @click="openEditModal(product)">
|
||||
ویرایش
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="filteredProducts.length" class="compact-only admin-card-list">
|
||||
<article v-for="product in filteredProducts" :key="product.id" class="admin-card-item">
|
||||
<div class="admin-card-item__header">
|
||||
<p class="admin-card-item__title">{{ product.name }}</p>
|
||||
<ActiveSwitchCell
|
||||
:id="product.id"
|
||||
v-model="product.isActive"
|
||||
:entity-name="product.name"
|
||||
entity-type="محصول"
|
||||
/>
|
||||
</div>
|
||||
<div class="admin-card-item__meta">
|
||||
<span>{{ product.productCode }}</span>
|
||||
<span>{{ product.color }}</span>
|
||||
<span>کالیته {{ product.catalogCode }}</span>
|
||||
<span>{{ product.price ? formatCurrency(product.price) : 'بدون قیمت' }}</span>
|
||||
</div>
|
||||
<div class="admin-card-item__actions">
|
||||
<button type="button" class="admin-btn admin-btn--ghost admin-btn--xs" @click="openEditModal(product)">
|
||||
ویرایش
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<BaseModal :open="isModalOpen" :title="modalTitle" size="lg" @close="closeModal">
|
||||
<form class="admin-form" @submit.prevent="submitProduct">
|
||||
<p v-if="formError" class="admin-form__error" role="alert">{{ formError }}</p>
|
||||
|
||||
<div class="admin-form__field">
|
||||
<label for="product-catalog">کالیته <span class="admin-form__required">*</span></label>
|
||||
<select id="product-catalog" v-model="form.catalogId" class="admin-input">
|
||||
<option v-for="catalog in catalogs" :key="catalog.id" :value="catalog.id">
|
||||
{{ catalog.code }} — {{ catalog.name }} ({{ catalog.companyName }})
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="admin-form__row">
|
||||
<div class="admin-form__field">
|
||||
<label for="product-name">نام محصول <span class="admin-form__required">*</span></label>
|
||||
<input id="product-name" v-model="form.name" type="text" class="admin-input" />
|
||||
</div>
|
||||
<div class="admin-form__field">
|
||||
<label for="product-color">رنگ / نوع</label>
|
||||
<input id="product-color" v-model="form.color" type="text" class="admin-input" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-form__field">
|
||||
<label for="product-code">کد محصول</label>
|
||||
<input id="product-code" v-model="form.productCode" type="text" class="admin-input" dir="ltr" />
|
||||
</div>
|
||||
|
||||
<div class="admin-form__row">
|
||||
<div class="admin-form__field">
|
||||
<label for="product-min">حداقل متراژ</label>
|
||||
<input id="product-min" v-model.number="form.minMeterage" type="number" min="1" class="admin-input" />
|
||||
</div>
|
||||
<div class="admin-form__field">
|
||||
<label for="product-step">ضریب سفارش</label>
|
||||
<input id="product-step" v-model.number="form.orderStep" type="number" min="1" class="admin-input" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-form__row">
|
||||
<div class="admin-form__field">
|
||||
<label for="product-price">قیمت (تومان)</label>
|
||||
<input id="product-price" v-model.number="form.price" type="number" min="0" class="admin-input" />
|
||||
</div>
|
||||
<div class="admin-form__field">
|
||||
<label for="product-stock">موجودی داخلی (متر)</label>
|
||||
<input id="product-stock" v-model.number="form.internalStock" type="number" min="0" class="admin-input" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-form__field">
|
||||
<BaseSwitch v-model="form.isActive" label="وضعیت" />
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<template #footer>
|
||||
<button type="button" class="admin-btn admin-btn--outline" @click="closeModal">انصراف</button>
|
||||
<button type="button" class="admin-btn admin-btn--primary" :disabled="!isFormValid" @click="submitProduct">
|
||||
{{ modalMode === 'edit' ? 'ذخیره تغییرات' : 'ثبت محصول' }}
|
||||
</button>
|
||||
</template>
|
||||
</BaseModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.products-page__filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 16px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.products-page__search {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.products-page__search-input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 0.875rem;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.products-page__catalog-filter {
|
||||
width: min(260px, 100%);
|
||||
}
|
||||
|
||||
.products-page__table {
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,174 @@
|
||||
<script setup lang="ts">
|
||||
import AdminIcon from '@/components/icons/AdminIcon.vue'
|
||||
import {
|
||||
dashboardStats,
|
||||
reportOrdersByStatus,
|
||||
reportSalesByCompany,
|
||||
} from '@/data/mockData'
|
||||
import { formatCurrency, formatMeterage, formatNumber } from '@/utils/format'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="reports-page">
|
||||
<div class="admin-page-header">
|
||||
<div>
|
||||
<h1 class="admin-page-header__title">گزارشها</h1>
|
||||
<p class="admin-page-header__breadcrumb">مدیریت / گزارشها</p>
|
||||
</div>
|
||||
<button type="button" class="admin-btn admin-btn--outline admin-btn--sm">
|
||||
<AdminIcon name="print" :size="16" />
|
||||
چاپ گزارش
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="reports-page__stats">
|
||||
<div class="admin-stat-card admin-card">
|
||||
<p class="admin-stat-card__label">فروش امروز</p>
|
||||
<p class="admin-stat-card__value">{{ formatCurrency(dashboardStats.todaySales) }}</p>
|
||||
<span class="reports-page__growth">+{{ dashboardStats.todaySalesGrowth }}% نسبت به دیروز</span>
|
||||
</div>
|
||||
<div class="admin-stat-card admin-card">
|
||||
<p class="admin-stat-card__label">فروش ماه جاری</p>
|
||||
<p class="admin-stat-card__value">{{ formatCurrency(dashboardStats.monthSales) }}</p>
|
||||
</div>
|
||||
<div class="admin-stat-card admin-card">
|
||||
<p class="admin-stat-card__label">سفارشهای جدید</p>
|
||||
<p class="admin-stat-card__value">{{ formatNumber(dashboardStats.newOrders) }}</p>
|
||||
</div>
|
||||
<div class="admin-stat-card admin-card">
|
||||
<p class="admin-stat-card__label">در انتظار بررسی</p>
|
||||
<p class="admin-stat-card__value">{{ formatNumber(dashboardStats.pendingReview) }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="reports-page__grid">
|
||||
<section class="admin-card reports-page__section">
|
||||
<h2 class="reports-page__section-title">فروش به تفکیک شرکت</h2>
|
||||
<div class="admin-table-wrap desktop-only">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>شرکت</th>
|
||||
<th>تعداد سفارش</th>
|
||||
<th>مجموع متراژ</th>
|
||||
<th>مجموع مبلغ</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in reportSalesByCompany" :key="row.companyName">
|
||||
<td>{{ row.companyName }}</td>
|
||||
<td>{{ formatNumber(row.orderCount) }}</td>
|
||||
<td>{{ formatMeterage(row.meterage) }}</td>
|
||||
<td>{{ formatCurrency(row.totalAmount) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="compact-only admin-card-list">
|
||||
<article v-for="row in reportSalesByCompany" :key="row.companyName" class="admin-card-item">
|
||||
<p class="admin-card-item__title">{{ row.companyName }}</p>
|
||||
<div class="admin-card-item__meta">
|
||||
<span>{{ row.orderCount }} سفارش</span>
|
||||
<span>{{ formatMeterage(row.meterage) }}</span>
|
||||
<span>{{ formatCurrency(row.totalAmount) }}</span>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="admin-card reports-page__section">
|
||||
<h2 class="reports-page__section-title">سفارشها به تفکیک وضعیت</h2>
|
||||
<div class="admin-table-wrap desktop-only">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>وضعیت</th>
|
||||
<th>تعداد</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in reportOrdersByStatus" :key="row.status">
|
||||
<td>{{ row.status }}</td>
|
||||
<td>{{ formatNumber(row.count) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<ul class="compact-only reports-page__status-list">
|
||||
<li v-for="row in reportOrdersByStatus" :key="row.status">
|
||||
<span>{{ row.status }}</span>
|
||||
<strong>{{ formatNumber(row.count) }}</strong>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.reports-page__stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.reports-page__growth {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-success-dark);
|
||||
margin-top: 6px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.reports-page__grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1.4fr 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.reports-page__section {
|
||||
padding: 20px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.reports-page__section-title {
|
||||
margin: 0 0 16px;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.reports-page__status-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.reports-page__status-list li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 12px;
|
||||
background: var(--color-border-light);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
@media (max-width: 1279px) {
|
||||
.reports-page__stats {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.reports-page__grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
.reports-page__stats {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,157 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive } from 'vue'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
|
||||
const toast = useToast()
|
||||
|
||||
const settings = reactive({
|
||||
maxMeteragePerInvoice: 5000,
|
||||
cashInvoiceValidityHours: 8,
|
||||
smsOrderRegistered: true,
|
||||
smsPaymentReminder: true,
|
||||
smsCheckReview: true,
|
||||
smsShippingUpdate: true,
|
||||
defaultSettlementCheck: true,
|
||||
defaultSettlementCash: true,
|
||||
supportPhone: '۰۲۱-۸۸۷۷۶۶۵۵',
|
||||
supportEmail: 'support@fabrictrade.ir',
|
||||
})
|
||||
|
||||
function saveSettings() {
|
||||
toast.success('تنظیمات با موفقیت ذخیره شد.')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="settings-page">
|
||||
<div class="admin-page-header">
|
||||
<div>
|
||||
<h1 class="admin-page-header__title">تنظیمات</h1>
|
||||
<p class="admin-page-header__breadcrumb">سیستم / تنظیمات</p>
|
||||
</div>
|
||||
<button type="button" class="admin-btn admin-btn--primary" @click="saveSettings">
|
||||
ذخیره تنظیمات
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="settings-page__grid">
|
||||
<section class="admin-card settings-page__section">
|
||||
<h2 class="settings-page__title">تنظیمات عمومی سفارش</h2>
|
||||
|
||||
<div class="admin-form__field">
|
||||
<label for="max-meterage">حداکثر متراژ هر فاکتور (متر)</label>
|
||||
<input
|
||||
id="max-meterage"
|
||||
v-model.number="settings.maxMeteragePerInvoice"
|
||||
type="number"
|
||||
min="1000"
|
||||
class="admin-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="admin-form__field">
|
||||
<label for="validity-hours">اعتبار فاکتور نقدی (ساعت)</label>
|
||||
<input
|
||||
id="validity-hours"
|
||||
v-model.number="settings.cashInvoiceValidityHours"
|
||||
type="number"
|
||||
min="1"
|
||||
class="admin-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="settings-page__checks">
|
||||
<label class="settings-page__check">
|
||||
<input v-model="settings.defaultSettlementCheck" type="checkbox" />
|
||||
<span>فعالسازی تسویه چکی</span>
|
||||
</label>
|
||||
<label class="settings-page__check">
|
||||
<input v-model="settings.defaultSettlementCash" type="checkbox" />
|
||||
<span>فعالسازی تسویه نقدی</span>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="admin-card settings-page__section">
|
||||
<h2 class="settings-page__title">پیامک و اعلان</h2>
|
||||
|
||||
<div class="settings-page__checks">
|
||||
<label class="settings-page__check">
|
||||
<input v-model="settings.smsOrderRegistered" type="checkbox" />
|
||||
<span>پیامک ثبت سفارش</span>
|
||||
</label>
|
||||
<label class="settings-page__check">
|
||||
<input v-model="settings.smsPaymentReminder" type="checkbox" />
|
||||
<span>یادآوری پایان اعتبار فاکتور</span>
|
||||
</label>
|
||||
<label class="settings-page__check">
|
||||
<input v-model="settings.smsCheckReview" type="checkbox" />
|
||||
<span>اعلان بررسی چک</span>
|
||||
</label>
|
||||
<label class="settings-page__check">
|
||||
<input v-model="settings.smsShippingUpdate" type="checkbox" />
|
||||
<span>اعلان وضعیت ارسال</span>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="admin-card settings-page__section">
|
||||
<h2 class="settings-page__title">اطلاعات تماس پشتیبانی</h2>
|
||||
|
||||
<div class="admin-form__field">
|
||||
<label for="support-phone">تلفن پشتیبانی</label>
|
||||
<input id="support-phone" v-model="settings.supportPhone" type="text" class="admin-input" />
|
||||
</div>
|
||||
|
||||
<div class="admin-form__field">
|
||||
<label for="support-email">ایمیل پشتیبانی</label>
|
||||
<input id="support-email" v-model="settings.supportEmail" type="email" class="admin-input" dir="ltr" />
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.settings-page__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.settings-page__section {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.settings-page__title {
|
||||
margin: 0 0 16px;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.settings-page__checks {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.settings-page__check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.settings-page__check input {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
accent-color: var(--color-primary);
|
||||
}
|
||||
|
||||
@media (max-width: 1279px) {
|
||||
.settings-page__grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,276 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import BaseModal from '@/components/ui/BaseModal.vue'
|
||||
import AdminIcon from '@/components/icons/AdminIcon.vue'
|
||||
import { shipments as initialShipments } from '@/data/mockData'
|
||||
import { SHIPMENT_STATUS_BADGE, SHIPMENT_STATUS_LABELS } from '@/utils/adminLabels'
|
||||
import { formatDateTime, formatMeterage } from '@/utils/format'
|
||||
import type { Shipment, ShipmentStatus } from '@/types'
|
||||
|
||||
const shipmentList = ref<Shipment[]>([...initialShipments])
|
||||
const searchQuery = ref('')
|
||||
const statusFilter = ref<'all' | ShipmentStatus>('all')
|
||||
const showModal = ref(false)
|
||||
const formError = ref('')
|
||||
|
||||
const emptyForm = () => ({
|
||||
orderNumber: '',
|
||||
customerName: '',
|
||||
carrier: '',
|
||||
trackingNumber: '',
|
||||
meterage: 1000,
|
||||
status: 'preparing' as ShipmentStatus,
|
||||
})
|
||||
|
||||
const form = reactive(emptyForm())
|
||||
|
||||
const filteredShipments = computed(() => {
|
||||
let list = shipmentList.value
|
||||
if (statusFilter.value !== 'all') {
|
||||
list = list.filter((s) => s.status === statusFilter.value)
|
||||
}
|
||||
const q = searchQuery.value.trim()
|
||||
if (q) {
|
||||
list = list.filter(
|
||||
(s) =>
|
||||
s.orderNumber.includes(q) ||
|
||||
s.customerName.includes(q) ||
|
||||
s.trackingNumber.includes(q) ||
|
||||
s.carrier.includes(q),
|
||||
)
|
||||
}
|
||||
return list
|
||||
})
|
||||
|
||||
const isFormValid = computed(
|
||||
() => form.orderNumber.trim() && form.customerName.trim() && form.carrier.trim(),
|
||||
)
|
||||
|
||||
function openAddModal() {
|
||||
Object.assign(form, emptyForm())
|
||||
formError.value = ''
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
showModal.value = false
|
||||
formError.value = ''
|
||||
}
|
||||
|
||||
function submitShipment() {
|
||||
if (!isFormValid.value) {
|
||||
formError.value = 'شماره سفارش، مشتری و باربری الزامی هستند.'
|
||||
return
|
||||
}
|
||||
|
||||
shipmentList.value.unshift({
|
||||
id: `shp-${crypto.randomUUID().slice(0, 8)}`,
|
||||
orderId: `ord-${crypto.randomUUID().slice(0, 6)}`,
|
||||
orderNumber: form.orderNumber.trim(),
|
||||
customerName: form.customerName.trim(),
|
||||
carrier: form.carrier.trim(),
|
||||
trackingNumber: form.trackingNumber.trim() || '—',
|
||||
meterage: form.meterage,
|
||||
status: form.status,
|
||||
shippedAt: form.status === 'shipped' || form.status === 'delivered' ? new Date().toISOString() : null,
|
||||
deliveredAt: form.status === 'delivered' ? new Date().toISOString() : null,
|
||||
})
|
||||
|
||||
closeModal()
|
||||
}
|
||||
|
||||
function advanceStatus(shipment: Shipment) {
|
||||
const flow: ShipmentStatus[] = ['preparing', 'ready', 'shipped', 'delivered']
|
||||
const index = flow.indexOf(shipment.status)
|
||||
if (index < flow.length - 1) {
|
||||
shipment.status = flow[index + 1]!
|
||||
if (shipment.status === 'shipped') shipment.shippedAt = new Date().toISOString()
|
||||
if (shipment.status === 'delivered') shipment.deliveredAt = new Date().toISOString()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="shipping-page">
|
||||
<div class="admin-page-header">
|
||||
<div>
|
||||
<h1 class="admin-page-header__title">ارسال و باربری</h1>
|
||||
<p class="admin-page-header__breadcrumb">عملیات / ارسال و باربری</p>
|
||||
</div>
|
||||
<button type="button" class="admin-btn admin-btn--primary" @click="openAddModal">
|
||||
ثبت بارنامه
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="shipping-page__filters admin-card">
|
||||
<div class="shipping-page__search">
|
||||
<AdminIcon name="search" :size="18" />
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="search"
|
||||
class="shipping-page__search-input"
|
||||
placeholder="جستجو در سفارش، مشتری، باربری..."
|
||||
/>
|
||||
</div>
|
||||
<select v-model="statusFilter" class="admin-input">
|
||||
<option value="all">همه وضعیتها</option>
|
||||
<option value="preparing">در حال آمادهسازی</option>
|
||||
<option value="ready">آماده ارسال</option>
|
||||
<option value="shipped">ارسال شده</option>
|
||||
<option value="delivered">تحویل شده</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="shipping-page__table admin-card desktop-only">
|
||||
<div class="admin-table-wrap">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>شماره سفارش</th>
|
||||
<th>مشتری</th>
|
||||
<th>باربری</th>
|
||||
<th>شماره بارنامه</th>
|
||||
<th>متراژ</th>
|
||||
<th>وضعیت</th>
|
||||
<th>تاریخ ارسال</th>
|
||||
<th>تاریخ تحویل</th>
|
||||
<th>عملیات</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="shipment in filteredShipments" :key="shipment.id">
|
||||
<td>{{ shipment.orderNumber }}</td>
|
||||
<td>{{ shipment.customerName }}</td>
|
||||
<td>{{ shipment.carrier }}</td>
|
||||
<td dir="ltr">{{ shipment.trackingNumber }}</td>
|
||||
<td>{{ formatMeterage(shipment.meterage) }}</td>
|
||||
<td>
|
||||
<span class="admin-badge" :class="SHIPMENT_STATUS_BADGE[shipment.status]">
|
||||
{{ SHIPMENT_STATUS_LABELS[shipment.status] }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ shipment.shippedAt ? formatDateTime(shipment.shippedAt) : '—' }}</td>
|
||||
<td>{{ shipment.deliveredAt ? formatDateTime(shipment.deliveredAt) : '—' }}</td>
|
||||
<td>
|
||||
<button
|
||||
v-if="shipment.status !== 'delivered'"
|
||||
type="button"
|
||||
class="admin-btn admin-btn--outline admin-btn--xs"
|
||||
@click="advanceStatus(shipment)"
|
||||
>
|
||||
مرحله بعد
|
||||
</button>
|
||||
<span v-else>—</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="compact-only admin-card-list">
|
||||
<article v-for="shipment in filteredShipments" :key="shipment.id" class="admin-card-item">
|
||||
<div class="admin-card-item__header">
|
||||
<p class="admin-card-item__title">{{ shipment.orderNumber }}</p>
|
||||
<span class="admin-badge" :class="SHIPMENT_STATUS_BADGE[shipment.status]">
|
||||
{{ SHIPMENT_STATUS_LABELS[shipment.status] }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="admin-card-item__meta">
|
||||
<span>{{ shipment.customerName }}</span>
|
||||
<span>{{ shipment.carrier }}</span>
|
||||
<span>{{ formatMeterage(shipment.meterage) }}</span>
|
||||
<span dir="ltr">{{ shipment.trackingNumber }}</span>
|
||||
</div>
|
||||
<div v-if="shipment.status !== 'delivered'" class="admin-card-item__actions">
|
||||
<button type="button" class="admin-btn admin-btn--outline admin-btn--xs" @click="advanceStatus(shipment)">
|
||||
مرحله بعد
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<BaseModal :open="showModal" title="ثبت بارنامه" size="lg" @close="closeModal">
|
||||
<form class="admin-form" @submit.prevent="submitShipment">
|
||||
<p v-if="formError" class="admin-form__error" role="alert">{{ formError }}</p>
|
||||
|
||||
<div class="admin-form__row">
|
||||
<div class="admin-form__field">
|
||||
<label for="ship-order">شماره سفارش <span class="admin-form__required">*</span></label>
|
||||
<input id="ship-order" v-model="form.orderNumber" type="text" class="admin-input" />
|
||||
</div>
|
||||
<div class="admin-form__field">
|
||||
<label for="ship-customer">مشتری <span class="admin-form__required">*</span></label>
|
||||
<input id="ship-customer" v-model="form.customerName" type="text" class="admin-input" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-form__row">
|
||||
<div class="admin-form__field">
|
||||
<label for="ship-carrier">باربری <span class="admin-form__required">*</span></label>
|
||||
<input id="ship-carrier" v-model="form.carrier" type="text" class="admin-input" />
|
||||
</div>
|
||||
<div class="admin-form__field">
|
||||
<label for="ship-tracking">شماره بارنامه</label>
|
||||
<input id="ship-tracking" v-model="form.trackingNumber" type="text" class="admin-input" dir="ltr" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-form__row">
|
||||
<div class="admin-form__field">
|
||||
<label for="ship-meterage">متراژ</label>
|
||||
<input id="ship-meterage" v-model.number="form.meterage" type="number" min="1" class="admin-input" />
|
||||
</div>
|
||||
<div class="admin-form__field">
|
||||
<label for="ship-status">وضعیت</label>
|
||||
<select id="ship-status" v-model="form.status" class="admin-input">
|
||||
<option value="preparing">در حال آمادهسازی</option>
|
||||
<option value="ready">آماده ارسال</option>
|
||||
<option value="shipped">ارسال شده</option>
|
||||
<option value="delivered">تحویل شده</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<template #footer>
|
||||
<button type="button" class="admin-btn admin-btn--outline" @click="closeModal">انصراف</button>
|
||||
<button type="button" class="admin-btn admin-btn--primary" :disabled="!isFormValid" @click="submitShipment">
|
||||
ثبت بارنامه
|
||||
</button>
|
||||
</template>
|
||||
</BaseModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.shipping-page__filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 16px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.shipping-page__search {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.shipping-page__search-input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 0.875rem;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.shipping-page__table {
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,284 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import BaseModal from '@/components/ui/BaseModal.vue'
|
||||
import BaseSwitch from '@/components/ui/BaseSwitch.vue'
|
||||
import BaseEmptyState from '@/components/ui/BaseEmptyState.vue'
|
||||
import ActiveSwitchCell from '@/components/ui/ActiveSwitchCell.vue'
|
||||
import AdminIcon from '@/components/icons/AdminIcon.vue'
|
||||
import { adminUsers as initialUsers } from '@/data/mockData'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { USER_ROLE_LABELS } from '@/utils/adminLabels'
|
||||
import { formatDateTime } from '@/utils/format'
|
||||
import type { AdminUser, UserRole } from '@/types'
|
||||
|
||||
const userList = ref<AdminUser[]>([...initialUsers])
|
||||
const searchQuery = ref('')
|
||||
const modalMode = ref<'add' | 'edit' | null>(null)
|
||||
const editingId = ref<string | null>(null)
|
||||
const formError = ref('')
|
||||
const saving = ref(false)
|
||||
const toast = useToast()
|
||||
|
||||
const emptyForm = () => ({
|
||||
fullName: '',
|
||||
phone: '',
|
||||
email: '',
|
||||
role: 'sales' as UserRole,
|
||||
isActive: true,
|
||||
})
|
||||
|
||||
const form = reactive(emptyForm())
|
||||
|
||||
const isModalOpen = computed(() => modalMode.value !== null)
|
||||
const modalTitle = computed(() => (modalMode.value === 'edit' ? 'ویرایش کاربر' : 'افزودن کاربر'))
|
||||
const isFormValid = computed(
|
||||
() => form.fullName.trim() && form.phone.trim() && form.email.trim(),
|
||||
)
|
||||
|
||||
const filteredUsers = computed(() => {
|
||||
const q = searchQuery.value.trim()
|
||||
if (!q) return userList.value
|
||||
return userList.value.filter(
|
||||
(u) =>
|
||||
u.fullName.includes(q) ||
|
||||
u.phone.includes(q) ||
|
||||
u.email.includes(q) ||
|
||||
USER_ROLE_LABELS[u.role].includes(q),
|
||||
)
|
||||
})
|
||||
|
||||
function openAddModal() {
|
||||
modalMode.value = 'add'
|
||||
editingId.value = null
|
||||
Object.assign(form, emptyForm())
|
||||
formError.value = ''
|
||||
}
|
||||
|
||||
function openEditModal(user: AdminUser) {
|
||||
modalMode.value = 'edit'
|
||||
editingId.value = user.id
|
||||
form.fullName = user.fullName
|
||||
form.phone = user.phone
|
||||
form.email = user.email
|
||||
form.role = user.role
|
||||
form.isActive = user.isActive
|
||||
formError.value = ''
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
modalMode.value = null
|
||||
editingId.value = null
|
||||
formError.value = ''
|
||||
}
|
||||
|
||||
function submitUser() {
|
||||
if (!isFormValid.value) {
|
||||
formError.value = 'نام، تلفن و ایمیل الزامی هستند.'
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
const payload = {
|
||||
fullName: form.fullName.trim(),
|
||||
phone: form.phone.trim(),
|
||||
email: form.email.trim(),
|
||||
role: form.role,
|
||||
isActive: form.isActive,
|
||||
}
|
||||
|
||||
if (modalMode.value === 'edit' && editingId.value) {
|
||||
const index = userList.value.findIndex((u) => u.id === editingId.value)
|
||||
if (index !== -1) {
|
||||
userList.value[index] = { ...userList.value[index]!, ...payload }
|
||||
}
|
||||
} else {
|
||||
userList.value.push({
|
||||
id: `usr-${crypto.randomUUID().slice(0, 8)}`,
|
||||
...payload,
|
||||
lastLoginAt: null,
|
||||
})
|
||||
}
|
||||
|
||||
toast.success(modalMode.value === 'edit' ? 'اطلاعات کاربر بهروزرسانی شد.' : 'کاربر جدید ثبت شد.')
|
||||
saving.value = false
|
||||
closeModal()
|
||||
}
|
||||
|
||||
function clearSearch() {
|
||||
searchQuery.value = ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="users-page">
|
||||
<div class="admin-page-header">
|
||||
<div>
|
||||
<h1 class="admin-page-header__title">کاربران و دسترسیها</h1>
|
||||
<p class="admin-page-header__breadcrumb">سیستم / کاربران</p>
|
||||
</div>
|
||||
<button type="button" class="admin-btn admin-btn--primary" @click="openAddModal">
|
||||
افزودن کاربر
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="users-page__search admin-card">
|
||||
<AdminIcon name="search" :size="18" />
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="search"
|
||||
class="users-page__search-input"
|
||||
placeholder="جستجو در نام، تلفن، ایمیل..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<BaseEmptyState
|
||||
v-if="!filteredUsers.length"
|
||||
class="admin-card"
|
||||
icon="users"
|
||||
:title="searchQuery.trim() ? 'نتیجهای یافت نشد' : 'کاربری ثبت نشده'"
|
||||
:description="
|
||||
searchQuery.trim()
|
||||
? 'عبارت جستجو را تغییر دهید یا فیلترها را پاک کنید.'
|
||||
: 'برای شروع، اولین کاربر را ثبت کنید.'
|
||||
"
|
||||
>
|
||||
<button v-if="searchQuery.trim()" type="button" class="admin-btn admin-btn--outline admin-btn--sm" @click="clearSearch">
|
||||
پاک کردن جستجو
|
||||
</button>
|
||||
<button v-else type="button" class="admin-btn admin-btn--primary admin-btn--sm" @click="openAddModal">
|
||||
افزودن کاربر
|
||||
</button>
|
||||
</BaseEmptyState>
|
||||
|
||||
<div v-else class="users-page__table admin-card desktop-only">
|
||||
<div class="admin-table-wrap">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>نام</th>
|
||||
<th>تلفن</th>
|
||||
<th>ایمیل</th>
|
||||
<th>نقش</th>
|
||||
<th class="status">وضعیت</th>
|
||||
<th>آخرین ورود</th>
|
||||
<th class="actions">عملیات</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="user in filteredUsers" :key="user.id">
|
||||
<td>{{ user.fullName }}</td>
|
||||
<td>{{ user.phone }}</td>
|
||||
<td dir="ltr">{{ user.email }}</td>
|
||||
<td>{{ USER_ROLE_LABELS[user.role] }}</td>
|
||||
<td class="status">
|
||||
<ActiveSwitchCell
|
||||
:id="user.id"
|
||||
v-model="user.isActive"
|
||||
:entity-name="user.fullName"
|
||||
entity-type="کاربر"
|
||||
/>
|
||||
</td>
|
||||
<td>{{ user.lastLoginAt ? formatDateTime(user.lastLoginAt) : '—' }}</td>
|
||||
<td class="actions">
|
||||
<button type="button" class="admin-btn admin-btn--ghost admin-btn--xs" @click="openEditModal(user)">
|
||||
ویرایش
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="filteredUsers.length" class="compact-only admin-card-list">
|
||||
<article v-for="user in filteredUsers" :key="user.id" class="admin-card-item">
|
||||
<div class="admin-card-item__header">
|
||||
<p class="admin-card-item__title">{{ user.fullName }}</p>
|
||||
<ActiveSwitchCell
|
||||
:id="user.id"
|
||||
v-model="user.isActive"
|
||||
:entity-name="user.fullName"
|
||||
entity-type="کاربر"
|
||||
/>
|
||||
</div>
|
||||
<div class="admin-card-item__meta">
|
||||
<span>{{ USER_ROLE_LABELS[user.role] }}</span>
|
||||
<span>{{ user.phone }}</span>
|
||||
<span dir="ltr">{{ user.email }}</span>
|
||||
</div>
|
||||
<div class="admin-card-item__actions">
|
||||
<button type="button" class="admin-btn admin-btn--ghost admin-btn--xs" @click="openEditModal(user)">
|
||||
ویرایش
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<BaseModal :open="isModalOpen" :title="modalTitle" size="lg" @close="closeModal">
|
||||
<form class="admin-form" @submit.prevent="submitUser">
|
||||
<p v-if="formError" class="admin-form__error" role="alert">{{ formError }}</p>
|
||||
|
||||
<div class="admin-form__field">
|
||||
<label for="user-name">نام کامل <span class="admin-form__required">*</span></label>
|
||||
<input id="user-name" v-model="form.fullName" type="text" class="admin-input" />
|
||||
</div>
|
||||
|
||||
<div class="admin-form__row">
|
||||
<div class="admin-form__field">
|
||||
<label for="user-phone">تلفن <span class="admin-form__required">*</span></label>
|
||||
<input id="user-phone" v-model="form.phone" type="text" class="admin-input" />
|
||||
</div>
|
||||
<div class="admin-form__field">
|
||||
<label for="user-email">ایمیل <span class="admin-form__required">*</span></label>
|
||||
<input id="user-email" v-model="form.email" type="email" class="admin-input" dir="ltr" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="admin-form__field">
|
||||
<label for="user-role">نقش</label>
|
||||
<select id="user-role" v-model="form.role" class="admin-input">
|
||||
<option value="admin">مدیر سیستم</option>
|
||||
<option value="sales">واحد فروش</option>
|
||||
<option value="finance">واحد مالی</option>
|
||||
<option value="warehouse">انبار</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="admin-form__field">
|
||||
<BaseSwitch v-model="form.isActive" label="وضعیت" />
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<template #footer>
|
||||
<button type="button" class="admin-btn admin-btn--outline" @click="closeModal">انصراف</button>
|
||||
<button type="button" class="admin-btn admin-btn--primary" :disabled="!isFormValid || saving" @click="submitUser">
|
||||
<span v-if="saving" class="admin-btn__spinner" aria-hidden="true" />
|
||||
{{ modalMode === 'edit' ? 'ذخیره تغییرات' : 'ثبت کاربر' }}
|
||||
</button>
|
||||
</template>
|
||||
</BaseModal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.users-page__search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
margin-bottom: 16px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.users-page__search-input {
|
||||
flex: 1;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: 0.875rem;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.users-page__table {
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user