Initial commit: fabric-trade-admin panel with responsive layouts and UI refinements.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-03 12:14:12 +02:00
co-authored by Cursor
commit a8501f4604
75 changed files with 14787 additions and 0 deletions
+10
View File
@@ -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>
+629
View File
@@ -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;
}
+39
View File
@@ -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;
}
+144
View File
@@ -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;
}
+405
View File
@@ -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);
}
}
+228
View File
@@ -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>
+194
View File
@@ -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>
+561
View File
@@ -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>
+141
View File
@@ -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>
+132
View File
@@ -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>
+52
View File
@@ -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>
+90
View File
@@ -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>
+172
View File
@@ -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>
+163
View File
@@ -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>
+45
View File
@@ -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>
+29
View File
@@ -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>
+106
View File
@@ -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>
+56
View File
@@ -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 }
}
+61
View File
@@ -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,
}
}
+56
View File
@@ -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
}
+87
View File
@@ -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,
}
}
+166
View File
@@ -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
}
+34
View File
@@ -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'),
}
}
+742
View File
@@ -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 },
]
+63
View File
@@ -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
View File
@@ -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')
+105
View File
@@ -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
+190
View File
@@ -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
}
+41
View File
@@ -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',
}
+38
View File
@@ -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}`
}
+21
View File
@@ -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)
}
+26
View File
@@ -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>
+308
View File
@@ -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>
+211
View File
@@ -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>
+262
View File
@@ -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>
+510
View File
@@ -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>
+300
View File
@@ -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>
+9
View File
@@ -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>
+387
View File
@@ -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>
+12
View File
@@ -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>
+358
View File
@@ -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>
+174
View File
@@ -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>
+157
View File
@@ -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>
+276
View File
@@ -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>
+284
View File
@@ -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>