changes
This commit is contained in:
+2
-1
@@ -13,5 +13,6 @@ dump.sql
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
**/.
|
**/.
|
||||||
.cursor
|
.cursor
|
||||||
hot
|
.cache
|
||||||
QWEN.md
|
QWEN.md
|
||||||
|
hot
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(mkdir *)",
|
||||||
|
"Bash(docker exec *)",
|
||||||
|
"Bash(rm *)",
|
||||||
|
"Bash(npm install)",
|
||||||
|
"Bash(npm run *)",
|
||||||
|
"Bash(sed *)",
|
||||||
|
"Bash(mv *)",
|
||||||
|
"Bash(git checkout *)",
|
||||||
|
"Bash(cp *)",
|
||||||
|
"Bash(find *)",
|
||||||
|
"Bash(grep *)",
|
||||||
|
"Bash(php *)",
|
||||||
|
"Bash(ls *)",
|
||||||
|
"Bash(git add *)",
|
||||||
|
"Bash(git commit *)",
|
||||||
|
"Bash(git show *)",
|
||||||
|
"Bash(git *)",
|
||||||
|
"Bash(docker compose up *)",
|
||||||
|
"Bash(docker compose restart *)",
|
||||||
|
"Bash(docker compose ps)",
|
||||||
|
"Bash(docker compose build *)",
|
||||||
|
"Bash(curl *)",
|
||||||
|
"Bash(cat *)"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"$version": 3
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(mkdir *)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
# Design System Specification
|
||||||
|
|
||||||
|
This document formalizes the design system extracted from the Preline CMS Dashboard. The system relies heavily on **semantic CSS variables** mapped to Tailwind CSS classes, allowing for seamless dark mode support and theme switching.
|
||||||
|
|
||||||
|
## 1. Color Palette (Semantic)
|
||||||
|
The system uses a layered approach to backgrounds and text to create visual hierarchy.
|
||||||
|
|
||||||
|
| Name | Tailwind Class | Usage |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| **Primary** | `bg-primary` / `text-primary` | Main actions, active states, and brand highlights. |
|
||||||
|
| **Background (Base)** | `bg-background-2` | The lowest level background (page body). |
|
||||||
|
| **Surface/Layer** | `bg-layer` | Main content containers and cards. |
|
||||||
|
| **Surface Muted** | `bg-surface` | Secondary containers or highlighted internal areas. |
|
||||||
|
| **Navbar/Sidebar** | `bg-navbar-2` / `bg-sidebar-2` | Specialized background for navigation components. |
|
||||||
|
| **Foreground (Base)** | `text-foreground` | High-contrast primary text. |
|
||||||
|
| **Muted 1** | `text-muted-foreground-1` | Secondary text, labels, and descriptions. |
|
||||||
|
| **Muted 2** | `text-muted-foreground-2` | Placeholder text or tertiary information. |
|
||||||
|
| **Border (Subtle)** | `border-line-2` | Internal dividers and low-contrast separators. |
|
||||||
|
| **Border (Strong)** | `border-layer-line` | Main component boundaries and container edges. |
|
||||||
|
|
||||||
|
## 2. Typography
|
||||||
|
The system uses a sans-serif stack (Inter) as the default for the UI, with specific sizing for administrative density.
|
||||||
|
|
||||||
|
- **Heading 1 (Page):** `font-medium text-lg text-foreground` (Used for dashboard titles).
|
||||||
|
- **Heading 2 (Section):** `font-medium text-foreground` (Used for card titles).
|
||||||
|
- **Body Text (Standard):** `text-sm text-foreground` (The default UI scale).
|
||||||
|
- **Body Text (Condensed):** `text-[13px] text-muted-foreground-1` (Used for metadata, authors, and side-info).
|
||||||
|
- **Labels/Captions:** `font-medium text-xs uppercase text-muted-foreground-1` (Sidebar headers).
|
||||||
|
- **Navigation Items:** `text-sm font-medium` (Active) or `text-sm font-normal`.
|
||||||
|
|
||||||
|
## 3. Spacing & Layout
|
||||||
|
- **Layout Shell:**
|
||||||
|
- Header height: `pt-13.5` (~54px offset).
|
||||||
|
- Sidebar width: `w-60` (240px).
|
||||||
|
- Main container: `h-[calc(100dvh-62px)]` (Full height minus header).
|
||||||
|
- **Standard Card Padding:** `p-4` (Standardizes internal white space).
|
||||||
|
- **Internal Gaps:**
|
||||||
|
- `gap-x-1.5`: Icon + Text pairing.
|
||||||
|
- `gap-2`: Small grid elements or button groups.
|
||||||
|
- `gap-5`: Large content sections (e.g., Image + Text blocks).
|
||||||
|
|
||||||
|
## 4. Components Library (Tailwind Patterns)
|
||||||
|
|
||||||
|
### Buttons
|
||||||
|
- **Primary (Solid):** `bg-primary text-primary-foreground rounded-md text-xl font-semibold` (Icon-heavy).
|
||||||
|
- **Secondary (Soft):** `bg-secondary text-secondary-foreground rounded-lg hover:bg-secondary-hover` (Profile actions).
|
||||||
|
- **Ghost/Outline:** `border border-layer-line text-layer-foreground rounded-lg hover:bg-primary-50` (Tools/Refresh).
|
||||||
|
- **Soft Badge Link:** `bg-primary-500/10 border border-primary-200 text-primary-700 rounded-full` (Upsell/Badges).
|
||||||
|
|
||||||
|
### Navigation (Sidebar)
|
||||||
|
- **Container:** `flex flex-col gap-y-1`
|
||||||
|
- **Active Item:** `bg-sidebar-2-nav-active font-medium text-sidebar-2-nav-foreground`
|
||||||
|
- **Inactive Item:** `text-sidebar-2-nav-foreground hover:bg-sidebar-2-nav-hover`
|
||||||
|
- **Group Divider:** `pt-3 mt-3 border-t border-sidebar-2-divider`
|
||||||
|
|
||||||
|
### Card / Container
|
||||||
|
- **Main Wrapper:** `bg-layer border border-layer-line shadow-xs rounded-lg`
|
||||||
|
- **Header:** `py-3 px-4 border-b border-card-line`
|
||||||
|
- **Nested Card:** `p-4 bg-surface rounded-lg` (Used for CTAs inside sidebars).
|
||||||
|
|
||||||
|
### Input / Select (Pseudo)
|
||||||
|
- **Search Bar:** `p-1.5 ps-2.5 w-full inline-flex items-center rounded-lg bg-layer border border-layer-line shadow-xs`
|
||||||
|
- **KBD Shortcut:** `py-px px-1.5 border border-line-2 rounded-md text-[11px]`
|
||||||
|
|
||||||
|
## 5. Global Styles
|
||||||
|
- **Border Radius:**
|
||||||
|
- `rounded-lg`: Standard for cards, buttons, and inputs.
|
||||||
|
- `rounded-xl`: Used for floating dropdowns and modals.
|
||||||
|
- `rounded-full`: Used for avatars and pill-style badges.
|
||||||
|
- **Shadows:**
|
||||||
|
- `shadow-xs`: Default for persistent surface elements.
|
||||||
|
- `shadow-xl`: Elevated states for dropdown menus.
|
||||||
|
- **Transitions:** `transition-all duration-300` (Applied to sidebar and interactive overlays).
|
||||||
|
- **Interactive States:** Uses custom semantic hover/focus classes: `hover:bg-muted-hover`, `focus:bg-muted-focus`.
|
||||||
@@ -0,0 +1,659 @@
|
|||||||
|
---
|
||||||
|
name: ui-ux-pro-max
|
||||||
|
description: "UI/UX design intelligence for web and mobile. Includes 50+ styles, 161 color palettes, 57 font pairings, 161 product types, 99 UX guidelines, and 25 chart types across 10 stacks (React, Next.js, Vue, Svelte, SwiftUI, React Native, Flutter, Tailwind, shadcn/ui, and HTML/CSS). Actions: plan, build, create, design, implement, review, fix, improve, optimize, enhance, refactor, and check UI/UX code. Projects: website, landing page, dashboard, admin panel, e-commerce, SaaS, portfolio, blog, and mobile app. Elements: button, modal, navbar, sidebar, card, table, form, and chart. Styles: glassmorphism, claymorphism, minimalism, brutalism, neumorphism, bento grid, dark mode, responsive, skeuomorphism, and flat design. Topics: color systems, accessibility, animation, layout, typography, font pairing, spacing, interaction states, shadow, and gradient. Integrations: shadcn/ui MCP for component search and examples."
|
||||||
|
---
|
||||||
|
|
||||||
|
# UI/UX Pro Max - Design Intelligence
|
||||||
|
|
||||||
|
Comprehensive design guide for web and mobile applications. Contains 50+ styles, 161 color palettes, 57 font pairings, 161 product types with reasoning rules, 99 UX guidelines, and 25 chart types across 10 technology stacks. Searchable database with priority-based recommendations.
|
||||||
|
|
||||||
|
## When to Apply
|
||||||
|
|
||||||
|
This Skill should be used when the task involves **UI structure, visual design decisions, interaction patterns, or user experience quality control**.
|
||||||
|
|
||||||
|
### Must Use
|
||||||
|
|
||||||
|
This Skill must be invoked in the following situations:
|
||||||
|
|
||||||
|
- Designing new pages (Landing Page, Dashboard, Admin, SaaS, Mobile App)
|
||||||
|
- Creating or refactoring UI components (buttons, modals, forms, tables, charts, etc.)
|
||||||
|
- Choosing color schemes, typography systems, spacing standards, or layout systems
|
||||||
|
- Reviewing UI code for user experience, accessibility, or visual consistency
|
||||||
|
- Implementing navigation structures, animations, or responsive behavior
|
||||||
|
- Making product-level design decisions (style, information hierarchy, brand expression)
|
||||||
|
- Improving perceived quality, clarity, or usability of interfaces
|
||||||
|
|
||||||
|
### Recommended
|
||||||
|
|
||||||
|
This Skill is recommended in the following situations:
|
||||||
|
|
||||||
|
- UI looks "not professional enough" but the reason is unclear
|
||||||
|
- Receiving feedback on usability or experience
|
||||||
|
- Pre-launch UI quality optimization
|
||||||
|
- Aligning cross-platform design (Web / iOS / Android)
|
||||||
|
- Building design systems or reusable component libraries
|
||||||
|
|
||||||
|
### Skip
|
||||||
|
|
||||||
|
This Skill is not needed in the following situations:
|
||||||
|
|
||||||
|
- Pure backend logic development
|
||||||
|
- Only involving API or database design
|
||||||
|
- Performance optimization unrelated to the interface
|
||||||
|
- Infrastructure or DevOps work
|
||||||
|
- Non-visual scripts or automation tasks
|
||||||
|
|
||||||
|
**Decision criteria**: If the task will change how a feature **looks, feels, moves, or is interacted with**, this Skill should be used.
|
||||||
|
|
||||||
|
## Rule Categories by Priority
|
||||||
|
|
||||||
|
*For human/AI reference: follow priority 1→10 to decide which rule category to focus on first; use `--domain <Domain>` to query details when needed. Scripts do not read this table.*
|
||||||
|
|
||||||
|
| Priority | Category | Impact | Domain | Key Checks (Must Have) | Anti-Patterns (Avoid) |
|
||||||
|
|----------|----------|--------|--------|------------------------|------------------------|
|
||||||
|
| 1 | Accessibility | CRITICAL | `ux` | Contrast 4.5:1, Alt text, Keyboard nav, Aria-labels | Removing focus rings, Icon-only buttons without labels |
|
||||||
|
| 2 | Touch & Interaction | CRITICAL | `ux` | Min size 44×44px, 8px+ spacing, Loading feedback | Reliance on hover only, Instant state changes (0ms) |
|
||||||
|
| 3 | Performance | HIGH | `ux` | WebP/AVIF, Lazy loading, Reserve space (CLS < 0.1) | Layout thrashing, Cumulative Layout Shift |
|
||||||
|
| 4 | Style Selection | HIGH | `style`, `product` | Match product type, Consistency, SVG icons (no emoji) | Mixing flat & skeuomorphic randomly, Emoji as icons |
|
||||||
|
| 5 | Layout & Responsive | HIGH | `ux` | Mobile-first breakpoints, Viewport meta, No horizontal scroll | Horizontal scroll, Fixed px container widths, Disable zoom |
|
||||||
|
| 6 | Typography & Color | MEDIUM | `typography`, `color` | Base 16px, Line-height 1.5, Semantic color tokens | Text < 12px body, Gray-on-gray, Raw hex in components |
|
||||||
|
| 7 | Animation | MEDIUM | `ux` | Duration 150–300ms, Motion conveys meaning, Spatial continuity | Decorative-only animation, Animating width/height, No reduced-motion |
|
||||||
|
| 8 | Forms & Feedback | MEDIUM | `ux` | Visible labels, Error near field, Helper text, Progressive disclosure | Placeholder-only label, Errors only at top, Overwhelm upfront |
|
||||||
|
| 9 | Navigation Patterns | HIGH | `ux` | Predictable back, Bottom nav ≤5, Deep linking | Overloaded nav, Broken back behavior, No deep links |
|
||||||
|
| 10 | Charts & Data | LOW | `chart` | Legends, Tooltips, Accessible colors | Relying on color alone to convey meaning |
|
||||||
|
|
||||||
|
## Quick Reference
|
||||||
|
|
||||||
|
### 1. Accessibility (CRITICAL)
|
||||||
|
|
||||||
|
- `color-contrast` - Minimum 4.5:1 ratio for normal text (large text 3:1); Material Design
|
||||||
|
- `focus-states` - Visible focus rings on interactive elements (2–4px; Apple HIG, MD)
|
||||||
|
- `alt-text` - Descriptive alt text for meaningful images
|
||||||
|
- `aria-labels` - aria-label for icon-only buttons; accessibilityLabel in native (Apple HIG)
|
||||||
|
- `keyboard-nav` - Tab order matches visual order; full keyboard support (Apple HIG)
|
||||||
|
- `form-labels` - Use label with for attribute
|
||||||
|
- `skip-links` - Skip to main content for keyboard users
|
||||||
|
- `heading-hierarchy` - Sequential h1→h6, no level skip
|
||||||
|
- `color-not-only` - Don't convey info by color alone (add icon/text)
|
||||||
|
- `dynamic-type` - Support system text scaling; avoid truncation as text grows (Apple Dynamic Type, MD)
|
||||||
|
- `reduced-motion` - Respect prefers-reduced-motion; reduce/disable animations when requested (Apple Reduced Motion API, MD)
|
||||||
|
- `voiceover-sr` - Meaningful accessibilityLabel/accessibilityHint; logical reading order for VoiceOver/screen readers (Apple HIG, MD)
|
||||||
|
- `escape-routes` - Provide cancel/back in modals and multi-step flows (Apple HIG)
|
||||||
|
- `keyboard-shortcuts` - Preserve system and a11y shortcuts; offer keyboard alternatives for drag-and-drop (Apple HIG)
|
||||||
|
|
||||||
|
### 2. Touch & Interaction (CRITICAL)
|
||||||
|
|
||||||
|
- `touch-target-size` - Min 44×44pt (Apple) / 48×48dp (Material); extend hit area beyond visual bounds if needed
|
||||||
|
- `touch-spacing` - Minimum 8px/8dp gap between touch targets (Apple HIG, MD)
|
||||||
|
- `hover-vs-tap` - Use click/tap for primary interactions; don't rely on hover alone
|
||||||
|
- `loading-buttons` - Disable button during async operations; show spinner or progress
|
||||||
|
- `error-feedback` - Clear error messages near problem
|
||||||
|
- `cursor-pointer` - Add cursor-pointer to clickable elements (Web)
|
||||||
|
- `gesture-conflicts` - Avoid horizontal swipe on main content; prefer vertical scroll
|
||||||
|
- `tap-delay` - Use touch-action: manipulation to reduce 300ms delay (Web)
|
||||||
|
- `standard-gestures` - Use platform standard gestures consistently; don't redefine (e.g. swipe-back, pinch-zoom) (Apple HIG)
|
||||||
|
- `system-gestures` - Don't block system gestures (Control Center, back swipe, etc.) (Apple HIG)
|
||||||
|
- `press-feedback` - Visual feedback on press (ripple/highlight; MD state layers)
|
||||||
|
- `haptic-feedback` - Use haptic for confirmations and important actions; avoid overuse (Apple HIG)
|
||||||
|
- `gesture-alternative` - Don't rely on gesture-only interactions; always provide visible controls for critical actions
|
||||||
|
- `safe-area-awareness` - Keep primary touch targets away from notch, Dynamic Island, gesture bar and screen edges
|
||||||
|
- `no-precision-required` - Avoid requiring pixel-perfect taps on small icons or thin edges
|
||||||
|
- `swipe-clarity` - Swipe actions must show clear affordance or hint (chevron, label, tutorial)
|
||||||
|
- `drag-threshold` - Use a movement threshold before starting drag to avoid accidental drags
|
||||||
|
|
||||||
|
### 3. Performance (HIGH)
|
||||||
|
|
||||||
|
- `image-optimization` - Use WebP/AVIF, responsive images (srcset/sizes), lazy load non-critical assets
|
||||||
|
- `image-dimension` - Declare width/height or use aspect-ratio to prevent layout shift (Core Web Vitals: CLS)
|
||||||
|
- `font-loading` - Use font-display: swap/optional to avoid invisible text (FOIT); reserve space to reduce layout shift (MD)
|
||||||
|
- `font-preload` - Preload only critical fonts; avoid overusing preload on every variant
|
||||||
|
- `critical-css` - Prioritize above-the-fold CSS (inline critical CSS or early-loaded stylesheet)
|
||||||
|
- `lazy-loading` - Lazy load non-hero components via dynamic import / route-level splitting
|
||||||
|
- `bundle-splitting` - Split code by route/feature (React Suspense / Next.js dynamic) to reduce initial load and TTI
|
||||||
|
- `third-party-scripts` - Load third-party scripts async/defer; audit and remove unnecessary ones (MD)
|
||||||
|
- `reduce-reflows` - Avoid frequent layout reads/writes; batch DOM reads then writes
|
||||||
|
- `content-jumping` - Reserve space for async content to avoid layout jumps (Core Web Vitals: CLS)
|
||||||
|
- `lazy-load-below-fold` - Use loading="lazy" for below-the-fold images and heavy media
|
||||||
|
- `virtualize-lists` - Virtualize lists with 50+ items to improve memory efficiency and scroll performance
|
||||||
|
- `main-thread-budget` - Keep per-frame work under ~16ms for 60fps; move heavy tasks off main thread (HIG, MD)
|
||||||
|
- `progressive-loading` - Use skeleton screens / shimmer instead of long blocking spinners for >1s operations (Apple HIG)
|
||||||
|
- `input-latency` - Keep input latency under ~100ms for taps/scrolls (Material responsiveness standard)
|
||||||
|
- `tap-feedback-speed` - Provide visual feedback within 100ms of tap (Apple HIG)
|
||||||
|
- `debounce-throttle` - Use debounce/throttle for high-frequency events (scroll, resize, input)
|
||||||
|
- `offline-support` - Provide offline state messaging and basic fallback (PWA / mobile)
|
||||||
|
- `network-fallback` - Offer degraded modes for slow networks (lower-res images, fewer animations)
|
||||||
|
|
||||||
|
### 4. Style Selection (HIGH)
|
||||||
|
|
||||||
|
- `style-match` - Match style to product type (use `--design-system` for recommendations)
|
||||||
|
- `consistency` - Use same style across all pages
|
||||||
|
- `no-emoji-icons` - Use SVG icons (Heroicons, Lucide), not emojis
|
||||||
|
- `color-palette-from-product` - Choose palette from product/industry (search `--domain color`)
|
||||||
|
- `effects-match-style` - Shadows, blur, radius aligned with chosen style (glass / flat / clay etc.)
|
||||||
|
- `platform-adaptive` - Respect platform idioms (iOS HIG vs Material): navigation, controls, typography, motion
|
||||||
|
- `state-clarity` - Make hover/pressed/disabled states visually distinct while staying on-style (Material state layers)
|
||||||
|
- `elevation-consistent` - Use a consistent elevation/shadow scale for cards, sheets, modals; avoid random shadow values
|
||||||
|
- `dark-mode-pairing` - Design light/dark variants together to keep brand, contrast, and style consistent
|
||||||
|
- `icon-style-consistent` - Use one icon set/visual language (stroke width, corner radius) across the product
|
||||||
|
- `system-controls` - Prefer native/system controls over fully custom ones; only customize when branding requires it (Apple HIG)
|
||||||
|
- `blur-purpose` - Use blur to indicate background dismissal (modals, sheets), not as decoration (Apple HIG)
|
||||||
|
- `primary-action` - Each screen should have only one primary CTA; secondary actions visually subordinate (Apple HIG)
|
||||||
|
|
||||||
|
### 5. Layout & Responsive (HIGH)
|
||||||
|
|
||||||
|
- `viewport-meta` - width=device-width initial-scale=1 (never disable zoom)
|
||||||
|
- `mobile-first` - Design mobile-first, then scale up to tablet and desktop
|
||||||
|
- `breakpoint-consistency` - Use systematic breakpoints (e.g. 375 / 768 / 1024 / 1440)
|
||||||
|
- `readable-font-size` - Minimum 16px body text on mobile (avoids iOS auto-zoom)
|
||||||
|
- `line-length-control` - Mobile 35–60 chars per line; desktop 60–75 chars
|
||||||
|
- `horizontal-scroll` - No horizontal scroll on mobile; ensure content fits viewport width
|
||||||
|
- `spacing-scale` - Use 4pt/8dp incremental spacing system (Material Design)
|
||||||
|
- `touch-density` - Keep component spacing comfortable for touch: not cramped, not causing mis-taps
|
||||||
|
- `container-width` - Consistent max-width on desktop (max-w-6xl / 7xl)
|
||||||
|
- `z-index-management` - Define layered z-index scale (e.g. 0 / 10 / 20 / 40 / 100 / 1000)
|
||||||
|
- `fixed-element-offset` - Fixed navbar/bottom bar must reserve safe padding for underlying content
|
||||||
|
- `scroll-behavior` - Avoid nested scroll regions that interfere with the main scroll experience
|
||||||
|
- `viewport-units` - Prefer min-h-dvh over 100vh on mobile
|
||||||
|
- `orientation-support` - Keep layout readable and operable in landscape mode
|
||||||
|
- `content-priority` - Show core content first on mobile; fold or hide secondary content
|
||||||
|
- `visual-hierarchy` - Establish hierarchy via size, spacing, contrast — not color alone
|
||||||
|
|
||||||
|
### 6. Typography & Color (MEDIUM)
|
||||||
|
|
||||||
|
- `line-height` - Use 1.5-1.75 for body text
|
||||||
|
- `line-length` - Limit to 65-75 characters per line
|
||||||
|
- `font-pairing` - Match heading/body font personalities
|
||||||
|
- `font-scale` - Consistent type scale (e.g. 12 14 16 18 24 32)
|
||||||
|
- `contrast-readability` - Darker text on light backgrounds (e.g. slate-900 on white)
|
||||||
|
- `text-styles-system` - Use platform type system: iOS 11 Dynamic Type styles / Material 5 type roles (display, headline, title, body, label) (HIG, MD)
|
||||||
|
- `weight-hierarchy` - Use font-weight to reinforce hierarchy: Bold headings (600–700), Regular body (400), Medium labels (500) (MD)
|
||||||
|
- `color-semantic` - Define semantic color tokens (primary, secondary, error, surface, on-surface) not raw hex in components (Material color system)
|
||||||
|
- `color-dark-mode` - Dark mode uses desaturated / lighter tonal variants, not inverted colors; test contrast separately (HIG, MD)
|
||||||
|
- `color-accessible-pairs` - Foreground/background pairs must meet 4.5:1 (AA) or 7:1 (AAA); use tools to verify (WCAG, MD)
|
||||||
|
- `color-not-decorative-only` - Functional color (error red, success green) must include icon/text; avoid color-only meaning (HIG, MD)
|
||||||
|
- `truncation-strategy` - Prefer wrapping over truncation; when truncating use ellipsis and provide full text via tooltip/expand (Apple HIG)
|
||||||
|
- `letter-spacing` - Respect default letter-spacing per platform; avoid tight tracking on body text (HIG, MD)
|
||||||
|
- `number-tabular` - Use tabular/monospaced figures for data columns, prices, and timers to prevent layout shift
|
||||||
|
- `whitespace-balance` - Use whitespace intentionally to group related items and separate sections; avoid visual clutter (Apple HIG)
|
||||||
|
|
||||||
|
### 7. Animation (MEDIUM)
|
||||||
|
|
||||||
|
- `duration-timing` - Use 150–300ms for micro-interactions; complex transitions ≤400ms; avoid >500ms (MD)
|
||||||
|
- `transform-performance` - Use transform/opacity only; avoid animating width/height/top/left
|
||||||
|
- `loading-states` - Show skeleton or progress indicator when loading exceeds 300ms
|
||||||
|
- `excessive-motion` - Animate 1-2 key elements per view max
|
||||||
|
- `easing` - Use ease-out for entering, ease-in for exiting; avoid linear for UI transitions
|
||||||
|
- `motion-meaning` - Every animation must express a cause-effect relationship, not just be decorative (Apple HIG)
|
||||||
|
- `state-transition` - State changes (hover / active / expanded / collapsed / modal) should animate smoothly, not snap
|
||||||
|
- `continuity` - Page/screen transitions should maintain spatial continuity (shared element, directional slide) (Apple HIG)
|
||||||
|
- `parallax-subtle` - Use parallax sparingly; must respect reduced-motion and not cause disorientation (Apple HIG)
|
||||||
|
- `spring-physics` - Prefer spring/physics-based curves over linear or cubic-bezier for natural feel (Apple HIG fluid animations)
|
||||||
|
- `exit-faster-than-enter` - Exit animations shorter than enter (~60–70% of enter duration) to feel responsive (MD motion)
|
||||||
|
- `stagger-sequence` - Stagger list/grid item entrance by 30–50ms per item; avoid all-at-once or too-slow reveals (MD)
|
||||||
|
- `shared-element-transition` - Use shared element / hero transitions for visual continuity between screens (MD, HIG)
|
||||||
|
- `interruptible` - Animations must be interruptible; user tap/gesture cancels in-progress animation immediately (Apple HIG)
|
||||||
|
- `no-blocking-animation` - Never block user input during an animation; UI must stay interactive (Apple HIG)
|
||||||
|
- `fade-crossfade` - Use crossfade for content replacement within the same container (MD)
|
||||||
|
- `scale-feedback` - Subtle scale (0.95–1.05) on press for tappable cards/buttons; restore on release (HIG, MD)
|
||||||
|
- `gesture-feedback` - Drag, swipe, and pinch must provide real-time visual response tracking the finger (MD Motion)
|
||||||
|
- `hierarchy-motion` - Use translate/scale direction to express hierarchy: enter from below = deeper, exit upward = back (MD)
|
||||||
|
- `motion-consistency` - Unify duration/easing tokens globally; all animations share the same rhythm and feel
|
||||||
|
- `opacity-threshold` - Fading elements should not linger below opacity 0.2; either fade fully or remain visible
|
||||||
|
- `modal-motion` - Modals/sheets should animate from their trigger source (scale+fade or slide-in) for spatial context (HIG, MD)
|
||||||
|
- `navigation-direction` - Forward navigation animates left/up; backward animates right/down — keep direction logically consistent (HIG)
|
||||||
|
- `layout-shift-avoid` - Animations must not cause layout reflow or CLS; use transform for position changes
|
||||||
|
|
||||||
|
### 8. Forms & Feedback (MEDIUM)
|
||||||
|
|
||||||
|
- `input-labels` - Visible label per input (not placeholder-only)
|
||||||
|
- `error-placement` - Show error below the related field
|
||||||
|
- `submit-feedback` - Loading then success/error state on submit
|
||||||
|
- `required-indicators` - Mark required fields (e.g. asterisk)
|
||||||
|
- `empty-states` - Helpful message and action when no content
|
||||||
|
- `toast-dismiss` - Auto-dismiss toasts in 3-5s
|
||||||
|
- `confirmation-dialogs` - Confirm before destructive actions
|
||||||
|
- `input-helper-text` - Provide persistent helper text below complex inputs, not just placeholder (Material Design)
|
||||||
|
- `disabled-states` - Disabled elements use reduced opacity (0.38–0.5) + cursor change + semantic attribute (MD)
|
||||||
|
- `progressive-disclosure` - Reveal complex options progressively; don't overwhelm users upfront (Apple HIG)
|
||||||
|
- `inline-validation` - Validate on blur (not keystroke); show error only after user finishes input (MD)
|
||||||
|
- `input-type-keyboard` - Use semantic input types (email, tel, number) to trigger the correct mobile keyboard (HIG, MD)
|
||||||
|
- `password-toggle` - Provide show/hide toggle for password fields (MD)
|
||||||
|
- `autofill-support` - Use autocomplete / textContentType attributes so the system can autofill (HIG, MD)
|
||||||
|
- `undo-support` - Allow undo for destructive or bulk actions (e.g. "Undo delete" toast) (Apple HIG)
|
||||||
|
- `success-feedback` - Confirm completed actions with brief visual feedback (checkmark, toast, color flash) (MD)
|
||||||
|
- `error-recovery` - Error messages must include a clear recovery path (retry, edit, help link) (HIG, MD)
|
||||||
|
- `multi-step-progress` - Multi-step flows show step indicator or progress bar; allow back navigation (MD)
|
||||||
|
- `form-autosave` - Long forms should auto-save drafts to prevent data loss on accidental dismissal (Apple HIG)
|
||||||
|
- `sheet-dismiss-confirm` - Confirm before dismissing a sheet/modal with unsaved changes (Apple HIG)
|
||||||
|
- `error-clarity` - Error messages must state cause + how to fix (not just "Invalid input") (HIG, MD)
|
||||||
|
- `field-grouping` - Group related fields logically (fieldset/legend or visual grouping) (MD)
|
||||||
|
- `read-only-distinction` - Read-only state should be visually and semantically different from disabled (MD)
|
||||||
|
- `focus-management` - After submit error, auto-focus the first invalid field (WCAG, MD)
|
||||||
|
- `error-summary` - For multiple errors, show summary at top with anchor links to each field (WCAG)
|
||||||
|
- `touch-friendly-input` - Mobile input height ≥44px to meet touch target requirements (Apple HIG)
|
||||||
|
- `destructive-emphasis` - Destructive actions use semantic danger color (red) and are visually separated from primary actions (HIG, MD)
|
||||||
|
- `toast-accessibility` - Toasts must not steal focus; use aria-live="polite" for screen reader announcement (WCAG)
|
||||||
|
- `aria-live-errors` - Form errors use aria-live region or role="alert" to notify screen readers (WCAG)
|
||||||
|
- `contrast-feedback` - Error and success state colors must meet 4.5:1 contrast ratio (WCAG, MD)
|
||||||
|
- `timeout-feedback` - Request timeout must show clear feedback with retry option (MD)
|
||||||
|
|
||||||
|
### 9. Navigation Patterns (HIGH)
|
||||||
|
|
||||||
|
- `bottom-nav-limit` - Bottom navigation max 5 items; use labels with icons (Material Design)
|
||||||
|
- `drawer-usage` - Use drawer/sidebar for secondary navigation, not primary actions (Material Design)
|
||||||
|
- `back-behavior` - Back navigation must be predictable and consistent; preserve scroll/state (Apple HIG, MD)
|
||||||
|
- `deep-linking` - All key screens must be reachable via deep link / URL for sharing and notifications (Apple HIG, MD)
|
||||||
|
- `tab-bar-ios` - iOS: use bottom Tab Bar for top-level navigation (Apple HIG)
|
||||||
|
- `top-app-bar-android` - Android: use Top App Bar with navigation icon for primary structure (Material Design)
|
||||||
|
- `nav-label-icon` - Navigation items must have both icon and text label; icon-only nav harms discoverability (MD)
|
||||||
|
- `nav-state-active` - Current location must be visually highlighted (color, weight, indicator) in navigation (HIG, MD)
|
||||||
|
- `nav-hierarchy` - Primary nav (tabs/bottom bar) vs secondary nav (drawer/settings) must be clearly separated (MD)
|
||||||
|
- `modal-escape` - Modals and sheets must offer a clear close/dismiss affordance; swipe-down to dismiss on mobile (Apple HIG)
|
||||||
|
- `search-accessible` - Search must be easily reachable (top bar or tab); provide recent/suggested queries (MD)
|
||||||
|
- `breadcrumb-web` - Web: use breadcrumbs for 3+ level deep hierarchies to aid orientation (MD)
|
||||||
|
- `state-preservation` - Navigating back must restore previous scroll position, filter state, and input (HIG, MD)
|
||||||
|
- `gesture-nav-support` - Support system gesture navigation (iOS swipe-back, Android predictive back) without conflict (HIG, MD)
|
||||||
|
- `tab-badge` - Use badges on nav items sparingly to indicate unread/pending; clear after user visits (HIG, MD)
|
||||||
|
- `overflow-menu` - When actions exceed available space, use overflow/more menu instead of cramming (MD)
|
||||||
|
- `bottom-nav-top-level` - Bottom nav is for top-level screens only; never nest sub-navigation inside it (MD)
|
||||||
|
- `adaptive-navigation` - Large screens (≥1024px) prefer sidebar; small screens use bottom/top nav (Material Adaptive)
|
||||||
|
- `back-stack-integrity` - Never silently reset the navigation stack or unexpectedly jump to home (HIG, MD)
|
||||||
|
- `navigation-consistency` - Navigation placement must stay the same across all pages; don't change by page type
|
||||||
|
- `avoid-mixed-patterns` - Don't mix Tab + Sidebar + Bottom Nav at the same hierarchy level
|
||||||
|
- `modal-vs-navigation` - Modals must not be used for primary navigation flows; they break the user's path (HIG)
|
||||||
|
- `focus-on-route-change` - After page transition, move focus to main content region for screen reader users (WCAG)
|
||||||
|
- `persistent-nav` - Core navigation must remain reachable from deep pages; don't hide it entirely in sub-flows (HIG, MD)
|
||||||
|
- `destructive-nav-separation` - Dangerous actions (delete account, logout) must be visually and spatially separated from normal nav items (HIG, MD)
|
||||||
|
- `empty-nav-state` - When a nav destination is unavailable, explain why instead of silently hiding it (MD)
|
||||||
|
|
||||||
|
### 10. Charts & Data (LOW)
|
||||||
|
|
||||||
|
- `chart-type` - Match chart type to data type (trend → line, comparison → bar, proportion → pie/donut)
|
||||||
|
- `color-guidance` - Use accessible color palettes; avoid red/green only pairs for colorblind users (WCAG, MD)
|
||||||
|
- `data-table` - Provide table alternative for accessibility; charts alone are not screen-reader friendly (WCAG)
|
||||||
|
- `pattern-texture` - Supplement color with patterns, textures, or shapes so data is distinguishable without color (WCAG, MD)
|
||||||
|
- `legend-visible` - Always show legend; position near the chart, not detached below a scroll fold (MD)
|
||||||
|
- `tooltip-on-interact` - Provide tooltips/data labels on hover (Web) or tap (mobile) showing exact values (HIG, MD)
|
||||||
|
- `axis-labels` - Label axes with units and readable scale; avoid truncated or rotated labels on mobile
|
||||||
|
- `responsive-chart` - Charts must reflow or simplify on small screens (e.g. horizontal bar instead of vertical, fewer ticks)
|
||||||
|
- `empty-data-state` - Show meaningful empty state when no data exists ("No data yet" + guidance), not a blank chart (MD)
|
||||||
|
- `loading-chart` - Use skeleton or shimmer placeholder while chart data loads; don't show an empty axis frame
|
||||||
|
- `animation-optional` - Chart entrance animations must respect prefers-reduced-motion; data should be readable immediately (HIG)
|
||||||
|
- `large-dataset` - For 1000+ data points, aggregate or sample; provide drill-down for detail instead of rendering all (MD)
|
||||||
|
- `number-formatting` - Use locale-aware formatting for numbers, dates, currencies on axes and labels (HIG, MD)
|
||||||
|
- `touch-target-chart` - Interactive chart elements (points, segments) must have ≥44pt tap area or expand on touch (Apple HIG)
|
||||||
|
- `no-pie-overuse` - Avoid pie/donut for >5 categories; switch to bar chart for clarity
|
||||||
|
- `contrast-data` - Data lines/bars vs background ≥3:1; data text labels ≥4.5:1 (WCAG)
|
||||||
|
- `legend-interactive` - Legends should be clickable to toggle series visibility (MD)
|
||||||
|
- `direct-labeling` - For small datasets, label values directly on the chart to reduce eye travel
|
||||||
|
- `tooltip-keyboard` - Tooltip content must be keyboard-reachable and not rely on hover alone (WCAG)
|
||||||
|
- `sortable-table` - Data tables must support sorting with aria-sort indicating current sort state (WCAG)
|
||||||
|
- `axis-readability` - Axis ticks must not be cramped; maintain readable spacing, auto-skip on small screens
|
||||||
|
- `data-density` - Limit information density per chart to avoid cognitive overload; split into multiple charts if needed
|
||||||
|
- `trend-emphasis` - Emphasize data trends over decoration; avoid heavy gradients/shadows that obscure the data
|
||||||
|
- `gridline-subtle` - Grid lines should be low-contrast (e.g. gray-200) so they don't compete with data
|
||||||
|
- `focusable-elements` - Interactive chart elements (points, bars, slices) must be keyboard-navigable (WCAG)
|
||||||
|
- `screen-reader-summary` - Provide a text summary or aria-label describing the chart's key insight for screen readers (WCAG)
|
||||||
|
- `error-state-chart` - Data load failure must show error message with retry action, not a broken/empty chart
|
||||||
|
- `export-option` - For data-heavy products, offer CSV/image export of chart data
|
||||||
|
- `drill-down-consistency` - Drill-down interactions must maintain a clear back-path and hierarchy breadcrumb
|
||||||
|
- `time-scale-clarity` - Time series charts must clearly label time granularity (day/week/month) and allow switching
|
||||||
|
|
||||||
|
## How to Use
|
||||||
|
|
||||||
|
Search specific domains using the CLI tool below.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
Check if Python is installed:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 --version || python --version
|
||||||
|
```
|
||||||
|
|
||||||
|
If Python is not installed, install it based on user's OS:
|
||||||
|
|
||||||
|
**macOS:**
|
||||||
|
```bash
|
||||||
|
brew install python3
|
||||||
|
```
|
||||||
|
|
||||||
|
**Ubuntu/Debian:**
|
||||||
|
```bash
|
||||||
|
sudo apt update && sudo apt install python3
|
||||||
|
```
|
||||||
|
|
||||||
|
**Windows:**
|
||||||
|
```powershell
|
||||||
|
winget install Python.Python.3.12
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How to Use This Skill
|
||||||
|
|
||||||
|
Use this skill when the user requests any of the following:
|
||||||
|
|
||||||
|
| Scenario | Trigger Examples | Start From |
|
||||||
|
|----------|-----------------|------------|
|
||||||
|
| **New project / page** | "Build a landing page", "Build a dashboard" | Step 1 → Step 2 (design system) |
|
||||||
|
| **New component** | "Create a pricing card", "Add a modal" | Step 3 (domain search: style, ux) |
|
||||||
|
| **Choose style / color / font** | "What style fits a fintech app?", "Recommend a color palette" | Step 2 (design system) |
|
||||||
|
| **Review existing UI** | "Review this page for UX issues", "Check accessibility" | Quick Reference checklist above |
|
||||||
|
| **Fix a UI bug** | "Button hover is broken", "Layout shifts on load" | Quick Reference → relevant section |
|
||||||
|
| **Improve / optimize** | "Make this faster", "Improve mobile experience" | Step 3 (domain search: ux, react) |
|
||||||
|
| **Implement dark mode** | "Add dark mode support" | Step 3 (domain: style "dark mode") |
|
||||||
|
| **Add charts / data viz** | "Add an analytics dashboard chart" | Step 3 (domain: chart) |
|
||||||
|
| **Stack best practices** | "React performance tips"、"SwiftUI navigation" | Step 4 (stack search) |
|
||||||
|
|
||||||
|
Follow this workflow:
|
||||||
|
|
||||||
|
### Step 1: Analyze User Requirements
|
||||||
|
|
||||||
|
Extract key information from user request:
|
||||||
|
- **Product type**: Entertainment (social, video, music, gaming), Tool (scanner, editor, converter), Productivity (task manager, notes, calendar), or hybrid
|
||||||
|
- **Target audience**: C-end consumer users; consider age group, usage context (commute, leisure, work)
|
||||||
|
- **Style keywords**: playful, vibrant, minimal, dark mode, content-first, immersive, etc.
|
||||||
|
- **Stack**: React Native (this project's only tech stack)
|
||||||
|
|
||||||
|
### Step 2: Generate Design System (REQUIRED)
|
||||||
|
|
||||||
|
**Always start with `--design-system`** to get comprehensive recommendations with reasoning:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 skills/ui-ux-pro-max/scripts/search.py "<product_type> <industry> <keywords>" --design-system [-p "Project Name"]
|
||||||
|
```
|
||||||
|
|
||||||
|
This command:
|
||||||
|
1. Searches domains in parallel (product, style, color, landing, typography)
|
||||||
|
2. Applies reasoning rules from `ui-reasoning.csv` to select best matches
|
||||||
|
3. Returns complete design system: pattern, style, colors, typography, effects
|
||||||
|
4. Includes anti-patterns to avoid
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
```bash
|
||||||
|
python3 skills/ui-ux-pro-max/scripts/search.py "beauty spa wellness service" --design-system -p "Serenity Spa"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2b: Persist Design System (Master + Overrides Pattern)
|
||||||
|
|
||||||
|
To save the design system for **hierarchical retrieval across sessions**, add `--persist`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 skills/ui-ux-pro-max/scripts/search.py "<query>" --design-system --persist -p "Project Name"
|
||||||
|
```
|
||||||
|
|
||||||
|
This creates:
|
||||||
|
- `design-system/MASTER.md` — Global Source of Truth with all design rules
|
||||||
|
- `design-system/pages/` — Folder for page-specific overrides
|
||||||
|
|
||||||
|
**With page-specific override:**
|
||||||
|
```bash
|
||||||
|
python3 skills/ui-ux-pro-max/scripts/search.py "<query>" --design-system --persist -p "Project Name" --page "dashboard"
|
||||||
|
```
|
||||||
|
|
||||||
|
This also creates:
|
||||||
|
- `design-system/pages/dashboard.md` — Page-specific deviations from Master
|
||||||
|
|
||||||
|
**How hierarchical retrieval works:**
|
||||||
|
1. When building a specific page (e.g., "Checkout"), first check `design-system/pages/checkout.md`
|
||||||
|
2. If the page file exists, its rules **override** the Master file
|
||||||
|
3. If not, use `design-system/MASTER.md` exclusively
|
||||||
|
|
||||||
|
**Context-aware retrieval prompt:**
|
||||||
|
```
|
||||||
|
I am building the [Page Name] page. Please read design-system/MASTER.md.
|
||||||
|
Also check if design-system/pages/[page-name].md exists.
|
||||||
|
If the page file exists, prioritize its rules.
|
||||||
|
If not, use the Master rules exclusively.
|
||||||
|
Now, generate the code...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 3: Supplement with Detailed Searches (as needed)
|
||||||
|
|
||||||
|
After getting the design system, use domain searches to get additional details:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 skills/ui-ux-pro-max/scripts/search.py "<keyword>" --domain <domain> [-n <max_results>]
|
||||||
|
```
|
||||||
|
|
||||||
|
**When to use detailed searches:**
|
||||||
|
|
||||||
|
| Need | Domain | Example |
|
||||||
|
|------|--------|---------|
|
||||||
|
| Product type patterns | `product` | `--domain product "entertainment social"` |
|
||||||
|
| More style options | `style` | `--domain style "glassmorphism dark"` |
|
||||||
|
| Color palettes | `color` | `--domain color "entertainment vibrant"` |
|
||||||
|
| Font pairings | `typography` | `--domain typography "playful modern"` |
|
||||||
|
| Chart recommendations | `chart` | `--domain chart "real-time dashboard"` |
|
||||||
|
| UX best practices | `ux` | `--domain ux "animation accessibility"` |
|
||||||
|
| Alternative fonts | `typography` | `--domain typography "elegant luxury"` |
|
||||||
|
| Individual Google Fonts | `google-fonts` | `--domain google-fonts "sans serif popular variable"` |
|
||||||
|
| Landing structure | `landing` | `--domain landing "hero social-proof"` |
|
||||||
|
| React Native perf | `react` | `--domain react "rerender memo list"` |
|
||||||
|
| App interface a11y | `web` | `--domain web "accessibilityLabel touch safe-areas"` |
|
||||||
|
| AI prompt / CSS keywords | `prompt` | `--domain prompt "minimalism"` |
|
||||||
|
|
||||||
|
### Step 4: Stack Guidelines (React Native)
|
||||||
|
|
||||||
|
Get React Native implementation-specific best practices:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 skills/ui-ux-pro-max/scripts/search.py "<keyword>" --stack react-native
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Search Reference
|
||||||
|
|
||||||
|
### Available Domains
|
||||||
|
|
||||||
|
| Domain | Use For | Example Keywords |
|
||||||
|
|--------|---------|------------------|
|
||||||
|
| `product` | Product type recommendations | SaaS, e-commerce, portfolio, healthcare, beauty, service |
|
||||||
|
| `style` | UI styles, colors, effects | glassmorphism, minimalism, dark mode, brutalism |
|
||||||
|
| `typography` | Font pairings, Google Fonts | elegant, playful, professional, modern |
|
||||||
|
| `color` | Color palettes by product type | saas, ecommerce, healthcare, beauty, fintech, service |
|
||||||
|
| `landing` | Page structure, CTA strategies | hero, hero-centric, testimonial, pricing, social-proof |
|
||||||
|
| `chart` | Chart types, library recommendations | trend, comparison, timeline, funnel, pie |
|
||||||
|
| `ux` | Best practices, anti-patterns | animation, accessibility, z-index, loading |
|
||||||
|
| `google-fonts` | Individual Google Fonts lookup | sans serif, monospace, japanese, variable font, popular |
|
||||||
|
| `react` | React/Next.js performance | waterfall, bundle, suspense, memo, rerender, cache |
|
||||||
|
| `web` | App interface guidelines (iOS/Android/React Native) | accessibilityLabel, touch targets, safe areas, Dynamic Type |
|
||||||
|
| `prompt` | AI prompts, CSS keywords | (style name) |
|
||||||
|
|
||||||
|
### Available Stacks
|
||||||
|
|
||||||
|
| Stack | Focus |
|
||||||
|
|-------|-------|
|
||||||
|
| `react-native` | Components, Navigation, Lists |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Example Workflow
|
||||||
|
|
||||||
|
**User request:** "Make an AI search homepage."
|
||||||
|
|
||||||
|
### Step 1: Analyze Requirements
|
||||||
|
- Product type: Tool (AI search engine)
|
||||||
|
- Target audience: C-end users looking for fast, intelligent search
|
||||||
|
- Style keywords: modern, minimal, content-first, dark mode
|
||||||
|
- Stack: React Native
|
||||||
|
|
||||||
|
### Step 2: Generate Design System (REQUIRED)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 skills/ui-ux-pro-max/scripts/search.py "AI search tool modern minimal" --design-system -p "AI Search"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Output:** Complete design system with pattern, style, colors, typography, effects, and anti-patterns.
|
||||||
|
|
||||||
|
### Step 3: Supplement with Detailed Searches (as needed)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Get style options for a modern tool product
|
||||||
|
python3 skills/ui-ux-pro-max/scripts/search.py "minimalism dark mode" --domain style
|
||||||
|
|
||||||
|
# Get UX best practices for search interaction and loading
|
||||||
|
python3 skills/ui-ux-pro-max/scripts/search.py "search loading animation" --domain ux
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 4: Stack Guidelines
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 skills/ui-ux-pro-max/scripts/search.py "list performance navigation" --stack react-native
|
||||||
|
```
|
||||||
|
|
||||||
|
**Then:** Synthesize design system + detailed searches and implement the design.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Output Formats
|
||||||
|
|
||||||
|
The `--design-system` flag supports two output formats:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# ASCII box (default) - best for terminal display
|
||||||
|
python3 skills/ui-ux-pro-max/scripts/search.py "fintech crypto" --design-system
|
||||||
|
|
||||||
|
# Markdown - best for documentation
|
||||||
|
python3 skills/ui-ux-pro-max/scripts/search.py "fintech crypto" --design-system -f markdown
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tips for Better Results
|
||||||
|
|
||||||
|
### Query Strategy
|
||||||
|
|
||||||
|
- Use **multi-dimensional keywords** — combine product + industry + tone + density: `"entertainment social vibrant content-dense"` not just `"app"`
|
||||||
|
- Try different keywords for the same need: `"playful neon"` → `"vibrant dark"` → `"content-first minimal"`
|
||||||
|
- Use `--design-system` first for full recommendations, then `--domain` to deep-dive any dimension you're unsure about
|
||||||
|
- Always add `--stack react-native` for implementation-specific guidance
|
||||||
|
|
||||||
|
### Common Sticking Points
|
||||||
|
|
||||||
|
| Problem | What to Do |
|
||||||
|
|---------|------------|
|
||||||
|
| Can't decide on style/color | Re-run `--design-system` with different keywords |
|
||||||
|
| Dark mode contrast issues | Quick Reference §6: `color-dark-mode` + `color-accessible-pairs` |
|
||||||
|
| Animations feel unnatural | Quick Reference §7: `spring-physics` + `easing` + `exit-faster-than-enter` |
|
||||||
|
| Form UX is poor | Quick Reference §8: `inline-validation` + `error-clarity` + `focus-management` |
|
||||||
|
| Navigation feels confusing | Quick Reference §9: `nav-hierarchy` + `bottom-nav-limit` + `back-behavior` |
|
||||||
|
| Layout breaks on small screens | Quick Reference §5: `mobile-first` + `breakpoint-consistency` |
|
||||||
|
| Performance / jank | Quick Reference §3: `virtualize-lists` + `main-thread-budget` + `debounce-throttle` |
|
||||||
|
|
||||||
|
### Pre-Delivery Checklist
|
||||||
|
|
||||||
|
- Run `--domain ux "animation accessibility z-index loading"` as a UX validation pass before implementation
|
||||||
|
- Run through Quick Reference **§1–§3** (CRITICAL + HIGH) as a final review
|
||||||
|
- Test on 375px (small phone) and landscape orientation
|
||||||
|
- Verify behavior with **reduced-motion** enabled and **Dynamic Type** at largest size
|
||||||
|
- Check dark mode contrast independently (don't assume light mode values work)
|
||||||
|
- Confirm all touch targets ≥44pt and no content hidden behind safe areas
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Rules for Professional UI
|
||||||
|
|
||||||
|
These are frequently overlooked issues that make UI look unprofessional:
|
||||||
|
Scope notice: The rules below are for App UI (iOS/Android/React Native/Flutter), not desktop-web interaction patterns.
|
||||||
|
|
||||||
|
### Icons & Visual Elements
|
||||||
|
|
||||||
|
| Rule | Standard | Avoid | Why It Matters |
|
||||||
|
|------|----------|--------|----------------|
|
||||||
|
| **No Emoji as Structural Icons** | Use vector-based icons (e.g., Lucide, react-native-vector-icons, @expo/vector-icons). | Using emojis (🎨 🚀 ⚙️) for navigation, settings, or system controls. | Emojis are font-dependent, inconsistent across platforms, and cannot be controlled via design tokens. |
|
||||||
|
| **Vector-Only Assets** | Use SVG or platform vector icons that scale cleanly and support theming. | Raster PNG icons that blur or pixelate. | Ensures scalability, crisp rendering, and dark/light mode adaptability. |
|
||||||
|
| **Stable Interaction States** | Use color, opacity, or elevation transitions for press states without changing layout bounds. | Layout-shifting transforms that move surrounding content or trigger visual jitter. | Prevents unstable interactions and preserves smooth motion/perceived quality on mobile. |
|
||||||
|
| **Correct Brand Logos** | Use official brand assets and follow their usage guidelines (spacing, color, clear space). | Guessing logo paths, recoloring unofficially, or modifying proportions. | Prevents brand misuse and ensures legal/platform compliance. |
|
||||||
|
| **Consistent Icon Sizing** | Define icon sizes as design tokens (e.g., icon-sm, icon-md = 24pt, icon-lg). | Mixing arbitrary values like 20pt / 24pt / 28pt randomly. | Maintains rhythm and visual hierarchy across the interface. |
|
||||||
|
| **Stroke Consistency** | Use a consistent stroke width within the same visual layer (e.g., 1.5px or 2px). | Mixing thick and thin stroke styles arbitrarily. | Inconsistent strokes reduce perceived polish and cohesion. |
|
||||||
|
| **Filled vs Outline Discipline** | Use one icon style per hierarchy level. | Mixing filled and outline icons at the same hierarchy level. | Maintains semantic clarity and stylistic coherence. |
|
||||||
|
| **Touch Target Minimum** | Minimum 44×44pt interactive area (use hitSlop if icon is smaller). | Small icons without expanded tap area. | Meets accessibility and platform usability standards. |
|
||||||
|
| **Icon Alignment** | Align icons to text baseline and maintain consistent padding. | Misaligned icons or inconsistent spacing around them. | Prevents subtle visual imbalance that reduces perceived quality. |
|
||||||
|
| **Icon Contrast** | Follow WCAG contrast standards: 4.5:1 for small elements, 3:1 minimum for larger UI glyphs. | Low-contrast icons that blend into the background. | Ensures accessibility in both light and dark modes. |
|
||||||
|
|
||||||
|
|
||||||
|
### Interaction (App)
|
||||||
|
|
||||||
|
| Rule | Do | Don't |
|
||||||
|
|------|----|----- |
|
||||||
|
| **Tap feedback** | Provide clear pressed feedback (ripple/opacity/elevation) within 80-150ms | No visual response on tap |
|
||||||
|
| **Animation timing** | Keep micro-interactions around 150-300ms with platform-native easing | Instant transitions or slow animations (>500ms) |
|
||||||
|
| **Accessibility focus** | Ensure screen reader focus order matches visual order and labels are descriptive | Unlabeled controls or confusing focus traversal |
|
||||||
|
| **Disabled state clarity** | Use disabled semantics (`disabled`/native disabled props), reduced emphasis, and no tap action | Controls that look tappable but do nothing |
|
||||||
|
| **Touch target minimum** | Keep tap areas >=44x44pt (iOS) or >=48x48dp (Android), expand hit area when icon is smaller | Tiny tap targets or icon-only hit areas without padding |
|
||||||
|
| **Gesture conflict prevention** | Keep one primary gesture per region and avoid nested tap/drag conflicts | Overlapping gestures causing accidental actions |
|
||||||
|
| **Semantic native controls** | Prefer native interactive primitives (`Button`, `Pressable`, platform equivalents) with proper accessibility roles | Generic containers used as primary controls without semantics |
|
||||||
|
|
||||||
|
### Light/Dark Mode Contrast
|
||||||
|
|
||||||
|
| Rule | Do | Don't |
|
||||||
|
|------|----|----- |
|
||||||
|
| **Surface readability (light)** | Keep cards/surfaces clearly separated from background with sufficient opacity/elevation | Overly transparent surfaces that blur hierarchy |
|
||||||
|
| **Text contrast (light)** | Maintain body text contrast >=4.5:1 against light surfaces | Low-contrast gray body text |
|
||||||
|
| **Text contrast (dark)** | Maintain primary text contrast >=4.5:1 and secondary text >=3:1 on dark surfaces | Dark mode text that blends into background |
|
||||||
|
| **Border and divider visibility** | Ensure separators are visible in both themes (not just light mode) | Theme-specific borders disappearing in one mode |
|
||||||
|
| **State contrast parity** | Keep pressed/focused/disabled states equally distinguishable in light and dark themes | Defining interaction states for one theme only |
|
||||||
|
| **Token-driven theming** | Use semantic color tokens mapped per theme across app surfaces/text/icons | Hardcoded per-screen hex values |
|
||||||
|
| **Scrim and modal legibility** | Use a modal scrim strong enough to isolate foreground content (typically 40-60% black) | Weak scrim that leaves background visually competing |
|
||||||
|
|
||||||
|
### Layout & Spacing
|
||||||
|
|
||||||
|
| Rule | Do | Don't |
|
||||||
|
|------|----|----- |
|
||||||
|
| **Safe-area compliance** | Respect top/bottom safe areas for all fixed headers, tab bars, and CTA bars | Placing fixed UI under notch, status bar, or gesture area |
|
||||||
|
| **System bar clearance** | Add spacing for status/navigation bars and gesture home indicator | Let tappable content collide with OS chrome |
|
||||||
|
| **Consistent content width** | Keep predictable content width per device class (phone/tablet) | Mixing arbitrary widths between screens |
|
||||||
|
| **8dp spacing rhythm** | Use a consistent 4/8dp spacing system for padding/gaps/section spacing | Random spacing increments with no rhythm |
|
||||||
|
| **Readable text measure** | Keep long-form text readable on large devices (avoid edge-to-edge paragraphs on tablets) | Full-width long text that hurts readability |
|
||||||
|
| **Section spacing hierarchy** | Define clear vertical rhythm tiers (e.g., 16/24/32/48) by hierarchy | Similar UI levels with inconsistent spacing |
|
||||||
|
| **Adaptive gutters by breakpoint** | Increase horizontal insets on larger widths and in landscape | Same narrow gutter on all device sizes/orientations |
|
||||||
|
| **Scroll and fixed element coexistence** | Add bottom/top content insets so lists are not hidden behind fixed bars | Scroll content obscured by sticky headers/footers |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pre-Delivery Checklist
|
||||||
|
|
||||||
|
Before delivering UI code, verify these items:
|
||||||
|
Scope notice: This checklist is for App UI (iOS/Android/React Native/Flutter).
|
||||||
|
|
||||||
|
### Visual Quality
|
||||||
|
- [ ] No emojis used as icons (use SVG instead)
|
||||||
|
- [ ] All icons come from a consistent icon family and style
|
||||||
|
- [ ] Official brand assets are used with correct proportions and clear space
|
||||||
|
- [ ] Pressed-state visuals do not shift layout bounds or cause jitter
|
||||||
|
- [ ] Semantic theme tokens are used consistently (no ad-hoc per-screen hardcoded colors)
|
||||||
|
|
||||||
|
### Interaction
|
||||||
|
- [ ] All tappable elements provide clear pressed feedback (ripple/opacity/elevation)
|
||||||
|
- [ ] Touch targets meet minimum size (>=44x44pt iOS, >=48x48dp Android)
|
||||||
|
- [ ] Micro-interaction timing stays in the 150-300ms range with native-feeling easing
|
||||||
|
- [ ] Disabled states are visually clear and non-interactive
|
||||||
|
- [ ] Screen reader focus order matches visual order, and interactive labels are descriptive
|
||||||
|
- [ ] Gesture regions avoid nested/conflicting interactions (tap/drag/back-swipe conflicts)
|
||||||
|
|
||||||
|
### Light/Dark Mode
|
||||||
|
- [ ] Primary text contrast >=4.5:1 in both light and dark mode
|
||||||
|
- [ ] Secondary text contrast >=3:1 in both light and dark mode
|
||||||
|
- [ ] Dividers/borders and interaction states are distinguishable in both modes
|
||||||
|
- [ ] Modal/drawer scrim opacity is strong enough to preserve foreground legibility (typically 40-60% black)
|
||||||
|
- [ ] Both themes are tested before delivery (not inferred from a single theme)
|
||||||
|
|
||||||
|
### Layout
|
||||||
|
- [ ] Safe areas are respected for headers, tab bars, and bottom CTA bars
|
||||||
|
- [ ] Scroll content is not hidden behind fixed/sticky bars
|
||||||
|
- [ ] Verified on small phone, large phone, and tablet (portrait + landscape)
|
||||||
|
- [ ] Horizontal insets/gutters adapt correctly by device size and orientation
|
||||||
|
- [ ] 4/8dp spacing rhythm is maintained across component, section, and page levels
|
||||||
|
- [ ] Long-form text measure remains readable on larger devices (no edge-to-edge paragraphs)
|
||||||
|
|
||||||
|
### Accessibility
|
||||||
|
- [ ] All meaningful images/icons have accessibility labels
|
||||||
|
- [ ] Form fields have labels, hints, and clear error messages
|
||||||
|
- [ ] Color is not the only indicator
|
||||||
|
- [ ] Reduced motion and dynamic text size are supported without layout breakage
|
||||||
|
- [ ] Accessibility traits/roles/states (selected, disabled, expanded) are announced correctly
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
../../../src/ui-ux-pro-max/data
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
../../../src/ui-ux-pro-max/scripts
|
||||||
@@ -9,6 +9,3 @@ Route::middleware('access-check')->group(function () {
|
|||||||
Route::get('/news', IndexPostController::class)->name('client.post.index');
|
Route::get('/news', IndexPostController::class)->name('client.post.index');
|
||||||
Route::get('/news/{slug}', ShowPostController::class)->name('client.post.show');
|
Route::get('/news/{slug}', ShowPostController::class)->name('client.post.show');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Containers\Article\UI\WEB\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class CreateSlideRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'title' => 'nullable|string|max:255',
|
||||||
|
'content' => 'nullable|string|max:1000',
|
||||||
|
'image' => 'required|array',
|
||||||
|
'image.url' => 'required|string',
|
||||||
|
'link' => 'required|string|max:255',
|
||||||
|
'settings' => 'nullable|array',
|
||||||
|
'settings.text_position' => 'nullable|string|in:left,center,right',
|
||||||
|
'settings.link_text' => 'nullable|string|max:50',
|
||||||
|
'settings.shading' => 'nullable|string',
|
||||||
|
'color_theme' => 'required|string',
|
||||||
|
'is_active' => 'boolean',
|
||||||
|
'start_time' => 'nullable|date',
|
||||||
|
'end_time' => 'nullable|date|after_or_equal:start_time',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Containers\Article\UI\WEB\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class UpdateSlideRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'title' => 'nullable|string|max:255',
|
||||||
|
'content' => 'nullable|string|max:1000',
|
||||||
|
'image' => 'sometimes|array',
|
||||||
|
'image.url' => 'sometimes|required|string',
|
||||||
|
'link' => 'sometimes|required|string|max:255',
|
||||||
|
'settings' => 'nullable|array',
|
||||||
|
'settings.text_position' => 'nullable|string|in:left,center,right',
|
||||||
|
'settings.link_text' => 'nullable|string|max:50',
|
||||||
|
'settings.shading' => 'nullable|string',
|
||||||
|
'color_theme' => 'sometimes|required|string',
|
||||||
|
'is_active' => 'sometimes|boolean',
|
||||||
|
'start_time' => 'nullable|date',
|
||||||
|
'end_time' => 'nullable|date|after_or_equal:start_time',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Containers\Dashboard\Actions\AcademicJournals;
|
||||||
|
|
||||||
|
use App\Containers\Science\Models\AcademicJournal;
|
||||||
|
|
||||||
|
class CreateAcademicJournalAction
|
||||||
|
{
|
||||||
|
public function run(array $data): AcademicJournal
|
||||||
|
{
|
||||||
|
return AcademicJournal::create($data);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Containers\Dashboard\Actions\AcademicJournals;
|
||||||
|
|
||||||
|
use App\Containers\Science\Models\AcademicJournal;
|
||||||
|
|
||||||
|
class DeleteAcademicJournalAction
|
||||||
|
{
|
||||||
|
public function run(AcademicJournal $journal): bool
|
||||||
|
{
|
||||||
|
return $journal->delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Containers\Dashboard\Actions\AcademicJournals;
|
||||||
|
|
||||||
|
use App\Containers\Science\Models\AcademicJournal;
|
||||||
|
|
||||||
|
class ListAcademicJournalsAction
|
||||||
|
{
|
||||||
|
public function run(array $filters = []): array
|
||||||
|
{
|
||||||
|
$query = AcademicJournal::query();
|
||||||
|
|
||||||
|
// Поиск по названию
|
||||||
|
if (!empty($filters['search'])) {
|
||||||
|
$query->where('title', 'like', '%' . $filters['search'] . '%');
|
||||||
|
}
|
||||||
|
|
||||||
|
$journals = $query->orderBy('created_at', 'desc')->paginate(20)->withQueryString();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'journals' => $journals,
|
||||||
|
'filters' => $filters,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Containers\Dashboard\Actions\AcademicJournals;
|
||||||
|
|
||||||
|
use App\Containers\Science\Models\AcademicJournal;
|
||||||
|
|
||||||
|
class UpdateAcademicJournalAction
|
||||||
|
{
|
||||||
|
public function run(AcademicJournal $journal, array $data): AcademicJournal
|
||||||
|
{
|
||||||
|
$journal->update($data);
|
||||||
|
return $journal->fresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Containers\Dashboard\Actions\AdditionalEducations\Categories;
|
||||||
|
|
||||||
|
use App\Containers\AdditionalEducation\Models\AdditionalEducationCategory;
|
||||||
|
|
||||||
|
class CreateCategoryAction
|
||||||
|
{
|
||||||
|
public function run(array $data): AdditionalEducationCategory
|
||||||
|
{
|
||||||
|
return AdditionalEducationCategory::create($data);
|
||||||
|
}
|
||||||
|
}
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Containers\Dashboard\Actions\AdditionalEducations\Categories;
|
||||||
|
|
||||||
|
use App\Containers\AdditionalEducation\Models\AdditionalEducationCategory;
|
||||||
|
|
||||||
|
class DeleteCategoryAction
|
||||||
|
{
|
||||||
|
public function run(AdditionalEducationCategory $category): bool
|
||||||
|
{
|
||||||
|
return $category->delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
+40
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Containers\Dashboard\Actions\AdditionalEducations\Categories;
|
||||||
|
|
||||||
|
use App\Containers\AdditionalEducation\Models\AdditionalEducationCategory;
|
||||||
|
use App\Containers\AdditionalEducation\Models\DirectionAdditionalEducation;
|
||||||
|
|
||||||
|
class ListCategoriesAction
|
||||||
|
{
|
||||||
|
public function run(array $filters = []): array
|
||||||
|
{
|
||||||
|
$query = AdditionalEducationCategory::with(['direction']);
|
||||||
|
|
||||||
|
// Фильтр по направлению
|
||||||
|
if (!empty($filters['direction_id'])) {
|
||||||
|
$query->where('dir_addit_educat_id', $filters['direction_id']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Фильтр по активности
|
||||||
|
if (isset($filters['is_active']) && $filters['is_active'] !== '') {
|
||||||
|
$query->where('is_active', filter_var($filters['is_active'], FILTER_VALIDATE_BOOLEAN));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Поиск по названию
|
||||||
|
if (!empty($filters['search'])) {
|
||||||
|
$search = $filters['search'];
|
||||||
|
$query->where('title', 'like', '%' . $search . '%');
|
||||||
|
}
|
||||||
|
|
||||||
|
$categories = $query->orderBy('title')->paginate(20)->withQueryString();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'categories' => $categories,
|
||||||
|
'filters' => $filters,
|
||||||
|
'directions' => DirectionAdditionalEducation::where('is_active', true)
|
||||||
|
->orderBy('title')
|
||||||
|
->get(['id', 'title']),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Containers\Dashboard\Actions\AdditionalEducations\Categories;
|
||||||
|
|
||||||
|
use App\Containers\AdditionalEducation\Models\AdditionalEducationCategory;
|
||||||
|
|
||||||
|
class UpdateCategoryAction
|
||||||
|
{
|
||||||
|
public function run(AdditionalEducationCategory $category, array $data): AdditionalEducationCategory
|
||||||
|
{
|
||||||
|
$category->update($data);
|
||||||
|
return $category;
|
||||||
|
}
|
||||||
|
}
|
||||||
+20
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Containers\Dashboard\Actions\AdditionalEducations;
|
||||||
|
|
||||||
|
use App\Containers\AdditionalEducation\Models\AdditionalEducation;
|
||||||
|
use App\Containers\Dashboard\Tasks\AdditionalEducations\GenerateSearchDataTask;
|
||||||
|
|
||||||
|
class CreateAdditionalEducationAction
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly GenerateSearchDataTask $generateSearchDataTask,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function run(array $data): AdditionalEducation
|
||||||
|
{
|
||||||
|
$data['search_data'] = $this->generateSearchDataTask->run($data['content'] ?? []);
|
||||||
|
|
||||||
|
return AdditionalEducation::create($data);
|
||||||
|
}
|
||||||
|
}
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Containers\Dashboard\Actions\AdditionalEducations;
|
||||||
|
|
||||||
|
use App\Containers\AdditionalEducation\Models\AdditionalEducation;
|
||||||
|
|
||||||
|
class DeleteAdditionalEducationAction
|
||||||
|
{
|
||||||
|
public function run(AdditionalEducation $education): bool
|
||||||
|
{
|
||||||
|
return $education->delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Containers\Dashboard\Actions\AdditionalEducations\Directions;
|
||||||
|
|
||||||
|
use App\Containers\AdditionalEducation\Models\DirectionAdditionalEducation;
|
||||||
|
|
||||||
|
class CreateDirectionAction
|
||||||
|
{
|
||||||
|
public function run(array $data): DirectionAdditionalEducation
|
||||||
|
{
|
||||||
|
return DirectionAdditionalEducation::create($data);
|
||||||
|
}
|
||||||
|
}
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Containers\Dashboard\Actions\AdditionalEducations\Directions;
|
||||||
|
|
||||||
|
use App\Containers\AdditionalEducation\Models\DirectionAdditionalEducation;
|
||||||
|
|
||||||
|
class DeleteDirectionAction
|
||||||
|
{
|
||||||
|
public function run(DirectionAdditionalEducation $direction): bool
|
||||||
|
{
|
||||||
|
return $direction->delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
+31
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Containers\Dashboard\Actions\AdditionalEducations\Directions;
|
||||||
|
|
||||||
|
use App\Containers\AdditionalEducation\Models\DirectionAdditionalEducation;
|
||||||
|
|
||||||
|
class ListDirectionsAction
|
||||||
|
{
|
||||||
|
public function run(array $filters = []): array
|
||||||
|
{
|
||||||
|
$query = DirectionAdditionalEducation::query();
|
||||||
|
|
||||||
|
// Фильтр по активности
|
||||||
|
if (isset($filters['is_active']) && $filters['is_active'] !== '') {
|
||||||
|
$query->where('is_active', filter_var($filters['is_active'], FILTER_VALIDATE_BOOLEAN));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Поиск по названию
|
||||||
|
if (!empty($filters['search'])) {
|
||||||
|
$search = $filters['search'];
|
||||||
|
$query->where('title', 'like', '%' . $search . '%');
|
||||||
|
}
|
||||||
|
|
||||||
|
$directions = $query->orderBy('title')->paginate(20)->withQueryString();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'directions' => $directions,
|
||||||
|
'filters' => $filters,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Containers\Dashboard\Actions\AdditionalEducations\Directions;
|
||||||
|
|
||||||
|
use App\Containers\AdditionalEducation\Models\DirectionAdditionalEducation;
|
||||||
|
|
||||||
|
class UpdateDirectionAction
|
||||||
|
{
|
||||||
|
public function run(DirectionAdditionalEducation $direction, array $data): DirectionAdditionalEducation
|
||||||
|
{
|
||||||
|
$direction->update($data);
|
||||||
|
return $direction;
|
||||||
|
}
|
||||||
|
}
|
||||||
+55
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Containers\Dashboard\Actions\AdditionalEducations;
|
||||||
|
|
||||||
|
use App\Containers\AdditionalEducation\Models\AdditionalEducation;
|
||||||
|
use App\Containers\AdditionalEducation\Models\AdditionalEducationCategory;
|
||||||
|
use App\Ship\Enums\Education\FormEducation;
|
||||||
|
|
||||||
|
class ListAdditionalEducationsAction
|
||||||
|
{
|
||||||
|
public function run(array $filters = []): array
|
||||||
|
{
|
||||||
|
$query = AdditionalEducation::with(['category']);
|
||||||
|
|
||||||
|
// Фильтр по категории
|
||||||
|
if (!empty($filters['category_id'])) {
|
||||||
|
$query->where('category_id', $filters['category_id']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Фильтр по форме обучения
|
||||||
|
if (!empty($filters['form_education'])) {
|
||||||
|
$query->where('form_education', $filters['form_education']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Фильтр по активности
|
||||||
|
if (isset($filters['is_active']) && $filters['is_active'] !== '') {
|
||||||
|
$query->where('is_active', filter_var($filters['is_active'], FILTER_VALIDATE_BOOLEAN));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Поиск по названию или целевой аудитории
|
||||||
|
if (!empty($filters['search'])) {
|
||||||
|
$search = $filters['search'];
|
||||||
|
$query->where(function ($q) use ($search) {
|
||||||
|
$q->where('title', 'like', '%' . $search . '%')
|
||||||
|
->orWhere('target_group', 'like', '%' . $search . '%');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$educations = $query->orderBy('title')->paginate(20)->withQueryString();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'educations' => $educations,
|
||||||
|
'filters' => $filters,
|
||||||
|
'categories' => AdditionalEducationCategory::where('is_active', true)
|
||||||
|
->orderBy('title')
|
||||||
|
->get(['id', 'title']),
|
||||||
|
'educationForms' => array_map(fn($form) => [
|
||||||
|
'value' => $form->value,
|
||||||
|
'label' => $form->getLabel(),
|
||||||
|
'color' => $form->getColor(),
|
||||||
|
'name' => $form->name,
|
||||||
|
], FormEducation::cases()),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Containers\Dashboard\Actions\AdditionalEducations;
|
||||||
|
|
||||||
|
use App\Containers\AdditionalEducation\Models\AdditionalEducation;
|
||||||
|
use App\Containers\Dashboard\Tasks\AdditionalEducations\GenerateSearchDataTask;
|
||||||
|
|
||||||
|
class UpdateAdditionalEducationAction
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly GenerateSearchDataTask $generateSearchDataTask,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function run(AdditionalEducation $education, array $data): AdditionalEducation
|
||||||
|
{
|
||||||
|
if (isset($data['content'])) {
|
||||||
|
$data['search_data'] = $this->generateSearchDataTask->run($data['content']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$education->update($data);
|
||||||
|
|
||||||
|
return $education;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Containers\Dashboard\Actions\AdmissionCampaigns;
|
||||||
|
|
||||||
|
use App\Containers\Education\Models\AdmissionCampaign;
|
||||||
|
|
||||||
|
class CreateAdmissionCampaignAction
|
||||||
|
{
|
||||||
|
public function run(array $data): AdmissionCampaign
|
||||||
|
{
|
||||||
|
return AdmissionCampaign::create($data);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Containers\Dashboard\Actions\AdmissionCampaigns;
|
||||||
|
|
||||||
|
use App\Containers\Education\Models\AdmissionCampaign;
|
||||||
|
|
||||||
|
class DeleteAdmissionCampaignAction
|
||||||
|
{
|
||||||
|
public function run(AdmissionCampaign $campaign): bool
|
||||||
|
{
|
||||||
|
return $campaign->delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Containers\Dashboard\Actions\AdmissionCampaigns;
|
||||||
|
|
||||||
|
use App\Containers\Education\Models\AdmissionCampaign;
|
||||||
|
use App\Ship\Enums\Education\AdmissionCampaignStatus;
|
||||||
|
|
||||||
|
class ListAdmissionCampaignsAction
|
||||||
|
{
|
||||||
|
public function run(array $filters = []): array
|
||||||
|
{
|
||||||
|
$query = AdmissionCampaign::query();
|
||||||
|
|
||||||
|
// Фильтр по статусу
|
||||||
|
if (!empty($filters['status'])) {
|
||||||
|
$query->where('status', $filters['status']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Фильтр по учебному году
|
||||||
|
if (!empty($filters['academic_year'])) {
|
||||||
|
$query->where('academic_year', $filters['academic_year']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Поиск по названию
|
||||||
|
if (!empty($filters['search'])) {
|
||||||
|
$search = $filters['search'];
|
||||||
|
$query->where('name', 'like', '%' . $search . '%');
|
||||||
|
}
|
||||||
|
|
||||||
|
$campaigns = $query->orderBy('academic_year', 'desc')->paginate(20)->withQueryString();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'campaigns' => $campaigns,
|
||||||
|
'filters' => $filters,
|
||||||
|
'statuses' => array_map(fn($status) => [
|
||||||
|
'value' => $status->value,
|
||||||
|
'label' => $status->getLabel(),
|
||||||
|
'color' => $status->getColor(),
|
||||||
|
], AdmissionCampaignStatus::cases()),
|
||||||
|
'academicYears' => $this->generateAcademicYears(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function generateAcademicYears(): array
|
||||||
|
{
|
||||||
|
$currentYear = (int) date('Y') - 5;
|
||||||
|
$yearsAhead = 10;
|
||||||
|
$academicYears = [];
|
||||||
|
|
||||||
|
for ($i = 0; $i < $yearsAhead; $i++) {
|
||||||
|
$startYear = $currentYear + $i;
|
||||||
|
$endYear = $startYear + 1;
|
||||||
|
$academicYears[] = "{$startYear}/{$endYear}";
|
||||||
|
}
|
||||||
|
|
||||||
|
return $academicYears;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Containers\Dashboard\Actions\AdmissionCampaigns;
|
||||||
|
|
||||||
|
use App\Containers\Education\Models\AdmissionCampaign;
|
||||||
|
|
||||||
|
class UpdateAdmissionCampaignAction
|
||||||
|
{
|
||||||
|
public function run(AdmissionCampaign $campaign, array $data): AdmissionCampaign
|
||||||
|
{
|
||||||
|
$campaign->update($data);
|
||||||
|
return $campaign;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Containers\Dashboard\Actions\AdmissionPlans;
|
||||||
|
|
||||||
|
use App\Containers\Education\Models\AdmissionPlan;
|
||||||
|
|
||||||
|
class CreateAdmissionPlanAction
|
||||||
|
{
|
||||||
|
public function run(array $data): AdmissionPlan
|
||||||
|
{
|
||||||
|
return AdmissionPlan::create($data);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Containers\Dashboard\Actions\AdmissionPlans;
|
||||||
|
|
||||||
|
use App\Containers\Education\Models\AdmissionPlan;
|
||||||
|
|
||||||
|
class DeleteAdmissionPlanAction
|
||||||
|
{
|
||||||
|
public function run(AdmissionPlan $plan): bool
|
||||||
|
{
|
||||||
|
return $plan->delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Containers\Dashboard\Actions\AdmissionPlans;
|
||||||
|
|
||||||
|
use App\Containers\Education\Models\AdmissionPlan;
|
||||||
|
use App\Containers\Education\Models\AdmissionCampaign;
|
||||||
|
use App\Containers\Education\Models\EducationalProgram;
|
||||||
|
use App\Ship\Enums\Education\EducationalProgramStatus;
|
||||||
|
|
||||||
|
class ListAdmissionPlansAction
|
||||||
|
{
|
||||||
|
public function run(array $filters = []): array
|
||||||
|
{
|
||||||
|
$query = AdmissionPlan::with(['educationalProgram', 'admissionCampaign']);
|
||||||
|
|
||||||
|
// Фильтр по приемной кампании
|
||||||
|
if (!empty($filters['admission_campaigns_id'])) {
|
||||||
|
$query->where('admission_campaigns_id', $filters['admission_campaigns_id']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Фильтр по образовательной программе
|
||||||
|
if (!empty($filters['educational_programs_id'])) {
|
||||||
|
$query->where('educational_programs_id', $filters['educational_programs_id']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$plans = $query->orderBy('id', 'desc')->paginate(20)->withQueryString();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'plans' => $plans,
|
||||||
|
'filters' => $filters,
|
||||||
|
'admissionCampaigns' => AdmissionCampaign::orderBy('name')->get(['id', 'name', 'academic_year']),
|
||||||
|
'educationalPrograms' => EducationalProgram::whereIn('status', [
|
||||||
|
EducationalProgramStatus::PUBLISHED,
|
||||||
|
EducationalProgramStatus::IN_PROGRESS
|
||||||
|
])->orderBy('name')->get(['id', 'name']),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Containers\Dashboard\Actions\AdmissionPlans;
|
||||||
|
|
||||||
|
use App\Containers\Education\Models\AdmissionPlan;
|
||||||
|
|
||||||
|
class UpdateAdmissionPlanAction
|
||||||
|
{
|
||||||
|
public function run(AdmissionPlan $plan, array $data): AdmissionPlan
|
||||||
|
{
|
||||||
|
$plan->update($data);
|
||||||
|
return $plan;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Containers\Dashboard\Actions\Categories;
|
||||||
|
|
||||||
|
use App\Containers\Article\Models\Category;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class CreateCategoryAction
|
||||||
|
{
|
||||||
|
public function run(array $data): Category
|
||||||
|
{
|
||||||
|
$data['slug'] = Str::slug($data['title']);
|
||||||
|
|
||||||
|
return Category::create($data);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Containers\Dashboard\Actions\Categories;
|
||||||
|
|
||||||
|
use App\Containers\Article\Models\Category;
|
||||||
|
|
||||||
|
class DeleteCategoryAction
|
||||||
|
{
|
||||||
|
public function run(Category $category): bool
|
||||||
|
{
|
||||||
|
return $category->delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Containers\Dashboard\Actions\Categories;
|
||||||
|
|
||||||
|
use App\Containers\Article\Models\Category;
|
||||||
|
|
||||||
|
class ListCategoriesAction
|
||||||
|
{
|
||||||
|
public function run(array $filters = []): array
|
||||||
|
{
|
||||||
|
$query = Category::query();
|
||||||
|
|
||||||
|
// Поиск по названию
|
||||||
|
if (!empty($filters['search'])) {
|
||||||
|
$query->where('title', 'like', '%' . $filters['search'] . '%');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Фильтр по статусу
|
||||||
|
if (isset($filters['is_active']) && $filters['is_active'] !== '') {
|
||||||
|
$query->where('is_active', (bool) $filters['is_active']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$categories = $query->orderBy('title')->paginate(20)->withQueryString();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'categories' => $categories,
|
||||||
|
'filters' => $filters,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Containers\Dashboard\Actions\Categories;
|
||||||
|
|
||||||
|
use App\Containers\Article\Models\Category;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class UpdateCategoryAction
|
||||||
|
{
|
||||||
|
public function run(Category $category, array $data): Category
|
||||||
|
{
|
||||||
|
if (isset($data['title'])) {
|
||||||
|
$data['slug'] = Str::slug($data['title']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$category->update($data);
|
||||||
|
return $category->fresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Containers\Dashboard\Actions\ContactWidgets;
|
||||||
|
|
||||||
|
use App\Containers\Widget\Models\ContactWidget;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class CreateContactWidgetAction
|
||||||
|
{
|
||||||
|
public function run(array $data): ContactWidget
|
||||||
|
{
|
||||||
|
$data['slug'] = $data['slug'] ?? Str::slug($data['title']);
|
||||||
|
$data['is_active'] = $data['is_active'] ?? true;
|
||||||
|
|
||||||
|
return ContactWidget::create($data);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Containers\Dashboard\Actions\ContactWidgets;
|
||||||
|
|
||||||
|
use App\Containers\Widget\Models\ContactWidget;
|
||||||
|
|
||||||
|
class DeleteContactWidgetAction
|
||||||
|
{
|
||||||
|
public function run(ContactWidget $widget): bool
|
||||||
|
{
|
||||||
|
return $widget->delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Containers\Dashboard\Actions\ContactWidgets;
|
||||||
|
|
||||||
|
use App\Containers\Widget\Models\ContactWidget;
|
||||||
|
|
||||||
|
class ListContactWidgetsAction
|
||||||
|
{
|
||||||
|
public function run(array $filters = []): array
|
||||||
|
{
|
||||||
|
$query = ContactWidget::query();
|
||||||
|
|
||||||
|
// Поиск по названию
|
||||||
|
if (!empty($filters['search'])) {
|
||||||
|
$query->where('title', 'like', '%' . $filters['search'] . '%');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Фильтр по статусу
|
||||||
|
if (isset($filters['is_active']) && $filters['is_active'] !== '') {
|
||||||
|
$query->where('is_active', (bool) $filters['is_active']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$widgets = $query->orderByDesc('created_at')->paginate(20)->withQueryString();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'widgets' => $widgets,
|
||||||
|
'filters' => $filters,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Containers\Dashboard\Actions\ContactWidgets;
|
||||||
|
|
||||||
|
use App\Containers\Widget\Models\ContactWidget;
|
||||||
|
|
||||||
|
class UpdateContactWidgetAction
|
||||||
|
{
|
||||||
|
public function run(ContactWidget $widget, array $data): ContactWidget
|
||||||
|
{
|
||||||
|
$widget->update($data);
|
||||||
|
|
||||||
|
return $widget;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Containers\Dashboard\Actions\CustomForms;
|
||||||
|
|
||||||
|
use App\Containers\Widget\Models\CustomForm;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class CreateCustomFormAction
|
||||||
|
{
|
||||||
|
public function run(array $data): CustomForm
|
||||||
|
{
|
||||||
|
$data['form_id'] = $data['form_id'] ?? Str::slug($data['title']) . time();
|
||||||
|
$data['status'] = $data['status'] ?? 'published';
|
||||||
|
$data['settings'] = $data['settings'] ?? [];
|
||||||
|
$data['mail_settings'] = $data['mail_settings'] ?? [];
|
||||||
|
$data['columns'] = $data['columns'] ?? [];
|
||||||
|
|
||||||
|
return CustomForm::create($data);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Containers\Dashboard\Actions\CustomForms;
|
||||||
|
|
||||||
|
use App\Containers\Widget\Models\CustomForm;
|
||||||
|
|
||||||
|
class DeleteCustomFormAction
|
||||||
|
{
|
||||||
|
public function run(CustomForm $form): bool
|
||||||
|
{
|
||||||
|
return $form->delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Containers\Dashboard\Actions\CustomForms;
|
||||||
|
|
||||||
|
use App\Containers\Widget\Models\CustomFormResponse;
|
||||||
|
|
||||||
|
class DeleteFormResponseAction
|
||||||
|
{
|
||||||
|
public function run(CustomFormResponse $response): bool
|
||||||
|
{
|
||||||
|
return $response->delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Containers\Dashboard\Actions\CustomForms;
|
||||||
|
|
||||||
|
use App\Containers\Widget\Models\CustomForm;
|
||||||
|
|
||||||
|
class ListCustomFormsAction
|
||||||
|
{
|
||||||
|
public function run(array $filters = []): array
|
||||||
|
{
|
||||||
|
$query = CustomForm::query();
|
||||||
|
|
||||||
|
// Поиск по названию
|
||||||
|
if (!empty($filters['search'])) {
|
||||||
|
$query->where('title', 'like', '%' . $filters['search'] . '%');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Фильтр по статусу
|
||||||
|
if (!empty($filters['status'])) {
|
||||||
|
$query->where('status', $filters['status']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$forms = $query->orderByDesc('created_at')->paginate(20)->withQueryString();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'forms' => $forms,
|
||||||
|
'filters' => $filters,
|
||||||
|
'statuses' => [
|
||||||
|
['value' => 'published', 'label' => 'Опубликовано'],
|
||||||
|
['value' => 'hidden', 'label' => 'Скрыто'],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Containers\Dashboard\Actions\CustomForms;
|
||||||
|
|
||||||
|
use App\Containers\Widget\Models\CustomForm;
|
||||||
|
|
||||||
|
class ListFormResponsesAction
|
||||||
|
{
|
||||||
|
public function run(CustomForm $form, array $filters = []): array
|
||||||
|
{
|
||||||
|
$query = $form->responses()->with('form');
|
||||||
|
|
||||||
|
// Фильтр по статусу просмотра
|
||||||
|
if (isset($filters['checked']) && $filters['checked'] !== '') {
|
||||||
|
$query->where('checked', (bool) $filters['checked']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Поиск по ID
|
||||||
|
if (!empty($filters['search'])) {
|
||||||
|
$query->where('id', 'like', '%' . $filters['search'] . '%');
|
||||||
|
}
|
||||||
|
|
||||||
|
$responses = $query->orderByDesc('created_at')->paginate(20)->withQueryString();
|
||||||
|
|
||||||
|
// Динамические колонки на основе полей формы
|
||||||
|
$columns = collect($form->columns ?? [])->map(function ($field) {
|
||||||
|
return [
|
||||||
|
'name' => $field['data']['name_field'] ?? '',
|
||||||
|
'title' => $field['data']['title_field'] ?? '',
|
||||||
|
'type' => $field['type'] ?? 'text',
|
||||||
|
'options' => $this->extractOptions($field),
|
||||||
|
];
|
||||||
|
})->filter(fn($col) => !empty($col['name']))->values()->toArray();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'form' => $form,
|
||||||
|
'responses' => $responses,
|
||||||
|
'columns' => $columns,
|
||||||
|
'filters' => $filters,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function extractOptions(array $field): array
|
||||||
|
{
|
||||||
|
if (!in_array($field['type'], ['single_choice', 'multiple_choice'])) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return collect($field['data']['columns'] ?? [])
|
||||||
|
->mapWithKeys(fn($opt) => [$opt['name_field'] ?? '' => $opt['title_field'] ?? ''])
|
||||||
|
->toArray();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Containers\Dashboard\Actions\CustomForms;
|
||||||
|
|
||||||
|
use App\Containers\Widget\Models\CustomForm;
|
||||||
|
|
||||||
|
class UpdateCustomFormAction
|
||||||
|
{
|
||||||
|
public function run(CustomForm $form, array $data): CustomForm
|
||||||
|
{
|
||||||
|
$form->update($data);
|
||||||
|
|
||||||
|
return $form;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Containers\Dashboard\Actions\Departments;
|
||||||
|
|
||||||
|
use App\Containers\Education\Models\EducationalProgram;
|
||||||
|
use App\Containers\InstituteStructure\Models\Department;
|
||||||
|
use App\Services\App\Cache\DepartmentCacheService;
|
||||||
|
|
||||||
|
class AttachDepartmentProgramAction
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly DepartmentCacheService $cacheService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function run(Department $department, EducationalProgram $program): void
|
||||||
|
{
|
||||||
|
$department->programs()->attach($program->id);
|
||||||
|
|
||||||
|
$this->cacheService->clearAllCacheByModel();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Containers\Dashboard\Actions\Departments;
|
||||||
|
|
||||||
|
use App\Containers\InstituteStructure\Models\Department;
|
||||||
|
use App\Containers\User\Models\User;
|
||||||
|
use App\Services\App\Cache\DepartmentCacheService;
|
||||||
|
|
||||||
|
class AttachDepartmentTeacherAction
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly DepartmentCacheService $cacheService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function run(Department $department, User $user, array $data): void
|
||||||
|
{
|
||||||
|
$department->teachers()->attach($user->id, [
|
||||||
|
'teaching_position' => $data['teaching_position'],
|
||||||
|
'service_email' => $data['service_email'] ?? null,
|
||||||
|
'service_phone' => $data['service_phone'] ?? null,
|
||||||
|
'cabinet' => $data['cabinet'] ?? null,
|
||||||
|
'sort' => $department->teachers()->count() + 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->cacheService->clearAllCacheByModel();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Containers\Dashboard\Actions\Departments;
|
||||||
|
|
||||||
|
use App\Containers\InstituteStructure\Models\Department;
|
||||||
|
use App\Containers\User\Models\User;
|
||||||
|
use App\Services\App\Cache\DepartmentCacheService;
|
||||||
|
|
||||||
|
class AttachDepartmentWorkerAction
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly DepartmentCacheService $cacheService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function run(Department $department, User $user, array $data): void
|
||||||
|
{
|
||||||
|
$department->workers()->attach($user->id, [
|
||||||
|
'position' => $data['position'],
|
||||||
|
'service_email' => $data['service_email'] ?? null,
|
||||||
|
'service_phone' => $data['service_phone'] ?? null,
|
||||||
|
'cabinet' => $data['cabinet'] ?? null,
|
||||||
|
'sort' => $department->workers()->count() + 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->cacheService->clearAllCacheByModel();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Containers\Dashboard\Actions\Departments;
|
||||||
|
|
||||||
|
use App\Containers\Dashboard\Tasks\Content\GenerateSearchDataTask;
|
||||||
|
use App\Containers\InstituteStructure\Models\Department;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class CreateDepartmentAction
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly GenerateSearchDataTask $generateSearchDataTask,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function run(array $data): Department
|
||||||
|
{
|
||||||
|
// Генерируем slug если не передан
|
||||||
|
if (empty($data['slug'])) {
|
||||||
|
$data['slug'] = Str::slug($data['title']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Генерируем search_data из контента
|
||||||
|
if (!empty($data['content'])) {
|
||||||
|
$data['search_data'] = $this->generateSearchDataTask->run($data['content']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$department = Department::create($data);
|
||||||
|
|
||||||
|
// Создаем SEO
|
||||||
|
$this->generateSeo($department, $data);
|
||||||
|
|
||||||
|
return $department;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function generateSeo(Department $department, array $data): void
|
||||||
|
{
|
||||||
|
$title = $data['title'] ?? '';
|
||||||
|
$description = null;
|
||||||
|
|
||||||
|
// Извлекаем description из первого paragraph блока
|
||||||
|
if (!empty($data['content'])) {
|
||||||
|
foreach ($data['content'] as $block) {
|
||||||
|
if ($block['type'] === 'paragraph') {
|
||||||
|
$description = strip_tags($block['data']['content']);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$department->seo()->create([
|
||||||
|
'title' => $title,
|
||||||
|
'description' => Str::limit(htmlspecialchars($description ?? '', ENT_QUOTES, 'UTF-8'), 160),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Containers\Dashboard\Actions\Departments;
|
||||||
|
|
||||||
|
use App\Containers\InstituteStructure\Models\Department;
|
||||||
|
|
||||||
|
class DeleteDepartmentAction
|
||||||
|
{
|
||||||
|
public function run(Department $department): void
|
||||||
|
{
|
||||||
|
$department->delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Containers\Dashboard\Actions\Departments;
|
||||||
|
|
||||||
|
use App\Containers\Education\Models\EducationalProgram;
|
||||||
|
use App\Containers\InstituteStructure\Models\Department;
|
||||||
|
use App\Services\App\Cache\DepartmentCacheService;
|
||||||
|
|
||||||
|
class DetachDepartmentProgramAction
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly DepartmentCacheService $cacheService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function run(Department $department, EducationalProgram $program): void
|
||||||
|
{
|
||||||
|
$department->programs()->detach($program->id);
|
||||||
|
|
||||||
|
$this->cacheService->clearAllCacheByModel();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Containers\Dashboard\Actions\Departments;
|
||||||
|
|
||||||
|
use App\Containers\InstituteStructure\Models\Department;
|
||||||
|
use App\Containers\User\Models\User;
|
||||||
|
use App\Services\App\Cache\DepartmentCacheService;
|
||||||
|
|
||||||
|
class DetachDepartmentTeacherAction
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly DepartmentCacheService $cacheService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function run(Department $department, User $user): void
|
||||||
|
{
|
||||||
|
$department->teachers()->detach($user->id);
|
||||||
|
|
||||||
|
$this->cacheService->clearAllCacheByModel();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Containers\Dashboard\Actions\Departments;
|
||||||
|
|
||||||
|
use App\Containers\InstituteStructure\Models\Department;
|
||||||
|
use App\Containers\User\Models\User;
|
||||||
|
use App\Services\App\Cache\DepartmentCacheService;
|
||||||
|
|
||||||
|
class DetachDepartmentWorkerAction
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly DepartmentCacheService $cacheService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function run(Department $department, User $user): void
|
||||||
|
{
|
||||||
|
$department->workers()->detach($user->id);
|
||||||
|
|
||||||
|
$this->cacheService->clearAllCacheByModel();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Containers\Dashboard\Actions\Departments;
|
||||||
|
|
||||||
|
use App\Containers\InstituteStructure\Models\Department;
|
||||||
|
use App\Services\App\Cache\DepartmentCacheService;
|
||||||
|
|
||||||
|
class ListDepartmentProgramsAction
|
||||||
|
{
|
||||||
|
public function run(Department $department, array $filters = []): array
|
||||||
|
{
|
||||||
|
$query = $department->programs();
|
||||||
|
|
||||||
|
// Фильтр по статусу
|
||||||
|
if (!empty($filters['status'])) {
|
||||||
|
$query->where('status', $filters['status']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Поиск по названию
|
||||||
|
if (!empty($filters['search'])) {
|
||||||
|
$query->where('name', 'like', '%' . $filters['search'] . '%');
|
||||||
|
}
|
||||||
|
|
||||||
|
$programs = $query->orderBy('name')->paginate(20)->withQueryString();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'programs' => $programs,
|
||||||
|
'filters' => $filters,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Containers\Dashboard\Actions\Departments;
|
||||||
|
|
||||||
|
use App\Containers\InstituteStructure\Models\Department;
|
||||||
|
|
||||||
|
class ListDepartmentTeachersAction
|
||||||
|
{
|
||||||
|
public function run(Department $department, array $filters = []): array
|
||||||
|
{
|
||||||
|
$query = $department->teachers();
|
||||||
|
|
||||||
|
// Поиск по имени
|
||||||
|
if (!empty($filters['search'])) {
|
||||||
|
$query->where('name', 'like', '%' . $filters['search'] . '%');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Фильтр по должности
|
||||||
|
if (!empty($filters['position'])) {
|
||||||
|
$query->where('teaching_position', 'like', '%' . $filters['position'] . '%');
|
||||||
|
}
|
||||||
|
|
||||||
|
$teachers = $query->orderBy('teachers_departments.sort')->paginate(20)->withQueryString();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'teachers' => $teachers,
|
||||||
|
'filters' => $filters,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Containers\Dashboard\Actions\Departments;
|
||||||
|
|
||||||
|
use App\Containers\InstituteStructure\Models\Department;
|
||||||
|
use App\Containers\User\Models\User;
|
||||||
|
|
||||||
|
class ListDepartmentWorkersAction
|
||||||
|
{
|
||||||
|
public function run(Department $department, array $filters = []): array
|
||||||
|
{
|
||||||
|
$query = $department->workers();
|
||||||
|
|
||||||
|
// Поиск по имени
|
||||||
|
if (!empty($filters['search'])) {
|
||||||
|
$query->where('name', 'like', '%' . $filters['search'] . '%');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Фильтр по должности
|
||||||
|
if (!empty($filters['position'])) {
|
||||||
|
$query->where('position', 'like', '%' . $filters['position'] . '%');
|
||||||
|
}
|
||||||
|
|
||||||
|
$workers = $query->orderBy('workers_departments.sort')->paginate(20)->withQueryString();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'workers' => $workers,
|
||||||
|
'filters' => $filters,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Containers\Dashboard\Actions\Departments;
|
||||||
|
|
||||||
|
use App\Containers\InstituteStructure\Models\Department;
|
||||||
|
|
||||||
|
class ListDepartmentsAction
|
||||||
|
{
|
||||||
|
public function run(array $filters = []): array
|
||||||
|
{
|
||||||
|
$query = Department::query()->with(['faculty']);
|
||||||
|
|
||||||
|
// Фильтр по факультету
|
||||||
|
if (isset($filters['faculty_id']) && $filters['faculty_id'] !== '') {
|
||||||
|
$query->where('faculty_id', (int) $filters['faculty_id']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Фильтр по статусу
|
||||||
|
if (isset($filters['is_active']) && $filters['is_active'] !== '') {
|
||||||
|
$query->where('is_active', (bool) $filters['is_active']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Поиск по названию
|
||||||
|
if (!empty($filters['search'])) {
|
||||||
|
$query->where('title', 'like', '%' . $filters['search'] . '%');
|
||||||
|
}
|
||||||
|
|
||||||
|
$departments = $query->orderBy('title')->paginate(20)->withQueryString();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'departments' => $departments,
|
||||||
|
'filters' => $filters,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Containers\Dashboard\Actions\Departments;
|
||||||
|
|
||||||
|
use App\Containers\Dashboard\Tasks\Content\GenerateSearchDataTask;
|
||||||
|
use App\Containers\InstituteStructure\Models\Department;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class UpdateDepartmentAction
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly GenerateSearchDataTask $generateSearchDataTask,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function run(Department $department, array $data): Department
|
||||||
|
{
|
||||||
|
// Генерируем search_data из контента только если контент передан и не пуст
|
||||||
|
if (isset($data['content']) && !empty($data['content'])) {
|
||||||
|
$data['search_data'] = $this->generateSearchDataTask->run($data['content']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$department->update($data);
|
||||||
|
|
||||||
|
// Обновляем SEO
|
||||||
|
$this->updateSeo($department, $data);
|
||||||
|
|
||||||
|
return $department;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function updateSeo(Department $department, array $data): void
|
||||||
|
{
|
||||||
|
$title = $data['title'] ?? $department->title;
|
||||||
|
$description = null;
|
||||||
|
|
||||||
|
// Извлекаем description из первого paragraph блока
|
||||||
|
if (!empty($data['content'])) {
|
||||||
|
foreach ($data['content'] as $block) {
|
||||||
|
if ($block['type'] === 'paragraph') {
|
||||||
|
$description = strip_tags($block['data']['content']);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$seoData = [
|
||||||
|
'title' => $title,
|
||||||
|
'description' => Str::limit(htmlspecialchars($description ?? '', ENT_QUOTES, 'UTF-8'), 160),
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($department->seo) {
|
||||||
|
$department->seo->update($seoData);
|
||||||
|
} else {
|
||||||
|
$department->seo()->create($seoData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Containers\Dashboard\Actions\Departments;
|
||||||
|
|
||||||
|
use App\Containers\InstituteStructure\Models\Department;
|
||||||
|
use App\Containers\User\Models\User;
|
||||||
|
use App\Services\App\Cache\DepartmentCacheService;
|
||||||
|
|
||||||
|
class UpdateDepartmentTeacherAction
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly DepartmentCacheService $cacheService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function run(Department $department, User $user, array $data): void
|
||||||
|
{
|
||||||
|
$department->teachers()->updateExistingPivot($user->id, [
|
||||||
|
'teaching_position' => $data['teaching_position'],
|
||||||
|
'service_email' => $data['service_email'] ?? null,
|
||||||
|
'service_phone' => $data['service_phone'] ?? null,
|
||||||
|
'cabinet' => $data['cabinet'] ?? null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->cacheService->clearAllCacheByModel();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Containers\Dashboard\Actions\Departments;
|
||||||
|
|
||||||
|
use App\Containers\InstituteStructure\Models\Department;
|
||||||
|
use App\Containers\User\Models\User;
|
||||||
|
use App\Services\App\Cache\DepartmentCacheService;
|
||||||
|
|
||||||
|
class UpdateDepartmentWorkerAction
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly DepartmentCacheService $cacheService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function run(Department $department, User $user, array $data): void
|
||||||
|
{
|
||||||
|
$department->workers()->updateExistingPivot($user->id, [
|
||||||
|
'position' => $data['position'],
|
||||||
|
'service_email' => $data['service_email'] ?? null,
|
||||||
|
'service_phone' => $data['service_phone'] ?? null,
|
||||||
|
'cabinet' => $data['cabinet'] ?? null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->cacheService->clearAllCacheByModel();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Containers\Dashboard\Actions\DirectionStudies;
|
||||||
|
|
||||||
|
use App\Containers\Education\Models\DirectionStudy;
|
||||||
|
|
||||||
|
class CreateDirectionStudyAction
|
||||||
|
{
|
||||||
|
public function run(array $data): DirectionStudy
|
||||||
|
{
|
||||||
|
return DirectionStudy::create($data);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Containers\Dashboard\Actions\DirectionStudies;
|
||||||
|
|
||||||
|
use App\Containers\Education\Models\DirectionStudy;
|
||||||
|
|
||||||
|
class DeleteDirectionStudyAction
|
||||||
|
{
|
||||||
|
public function run(DirectionStudy $direction): bool
|
||||||
|
{
|
||||||
|
return $direction->delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Containers\Dashboard\Actions\DirectionStudies;
|
||||||
|
|
||||||
|
use App\Containers\Education\Models\DirectionStudy;
|
||||||
|
use App\Ship\Enums\Education\LevelEducational;
|
||||||
|
|
||||||
|
class ListDirectionStudiesAction
|
||||||
|
{
|
||||||
|
public function run(array $filters = []): array
|
||||||
|
{
|
||||||
|
$query = DirectionStudy::query();
|
||||||
|
|
||||||
|
// Фильтр по уровню образования
|
||||||
|
if (!empty($filters['lvl_edu'])) {
|
||||||
|
$query->where('lvl_edu', $filters['lvl_edu']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Поиск по коду или названию
|
||||||
|
if (!empty($filters['search'])) {
|
||||||
|
$search = $filters['search'];
|
||||||
|
$query->where(function ($q) use ($search) {
|
||||||
|
$q->where('code', 'like', '%' . $search . '%')
|
||||||
|
->orWhere('name', 'like', '%' . $search . '%');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$directions = $query->orderBy('code')->paginate(20)->withQueryString();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'directions' => $directions,
|
||||||
|
'filters' => $filters,
|
||||||
|
'educationLevels' => array_map(fn($level) => [
|
||||||
|
'value' => $level->value,
|
||||||
|
'label' => $level->getLabel(),
|
||||||
|
'color' => $level->getColor(),
|
||||||
|
], LevelEducational::cases()),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Containers\Dashboard\Actions\DirectionStudies;
|
||||||
|
|
||||||
|
use App\Containers\Education\Models\DirectionStudy;
|
||||||
|
|
||||||
|
class UpdateDirectionStudyAction
|
||||||
|
{
|
||||||
|
public function run(DirectionStudy $direction, array $data): DirectionStudy
|
||||||
|
{
|
||||||
|
$direction->update($data);
|
||||||
|
return $direction;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Containers\Dashboard\Actions\Divisions;
|
||||||
|
|
||||||
|
use App\Containers\InstituteStructure\Models\Division;
|
||||||
|
use App\Containers\User\Models\User;
|
||||||
|
use App\Services\App\Cache\DivisionCacheService;
|
||||||
|
|
||||||
|
class AttachDivisionWorkerAction
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly DivisionCacheService $cacheService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function run(Division $division, User $user, array $data): void
|
||||||
|
{
|
||||||
|
$division->workers()->attach($user->id, [
|
||||||
|
'administrativePosition' => $data['administrativePosition'],
|
||||||
|
'service_email' => $data['service_email'] ?? null,
|
||||||
|
'service_phone' => $data['service_phone'] ?? null,
|
||||||
|
'cabinet' => $data['cabinet'] ?? null,
|
||||||
|
'sort' => $data['sort'] ?? $division->workers()->count(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->cacheService->clearAllCacheByModel();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Containers\Dashboard\Actions\Divisions;
|
||||||
|
|
||||||
|
use App\Containers\Dashboard\Tasks\Content\GenerateSearchDataTask;
|
||||||
|
use App\Containers\InstituteStructure\Models\Division;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class CreateDivisionAction
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly GenerateSearchDataTask $generateSearchDataTask,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function run(array $data): Division
|
||||||
|
{
|
||||||
|
// Генерируем slug если не передан
|
||||||
|
if (empty($data['slug'])) {
|
||||||
|
$data['slug'] = Str::slug($data['title']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Генерируем search_data из описания
|
||||||
|
if (!empty($data['description'])) {
|
||||||
|
$data['search_data'] = $this->generateSearchDataTask->run($data['description']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$division = Division::create($data);
|
||||||
|
|
||||||
|
// Создаем SEO
|
||||||
|
$this->generateSeo($division, $data);
|
||||||
|
|
||||||
|
return $division;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function generateSeo(Division $division, array $data): void
|
||||||
|
{
|
||||||
|
$title = $data['title'] ?? '';
|
||||||
|
$description = null;
|
||||||
|
|
||||||
|
// Извлекаем description из первого paragraph блока
|
||||||
|
if (!empty($data['description'])) {
|
||||||
|
foreach ($data['description'] as $block) {
|
||||||
|
if ($block['type'] === 'paragraph') {
|
||||||
|
$description = strip_tags($block['data']['content']);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$division->seo()->create([
|
||||||
|
'title' => $title,
|
||||||
|
'description' => Str::limit(htmlspecialchars($description ?? '', ENT_QUOTES, 'UTF-8'), 160),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Containers\Dashboard\Actions\Divisions;
|
||||||
|
|
||||||
|
use App\Containers\InstituteStructure\Models\Division;
|
||||||
|
|
||||||
|
class DeleteDivisionAction
|
||||||
|
{
|
||||||
|
public function run(Division $division): void
|
||||||
|
{
|
||||||
|
$division->delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Containers\Dashboard\Actions\Divisions;
|
||||||
|
|
||||||
|
use App\Containers\InstituteStructure\Models\Division;
|
||||||
|
use App\Containers\User\Models\User;
|
||||||
|
use App\Services\App\Cache\DivisionCacheService;
|
||||||
|
|
||||||
|
class DetachDivisionWorkerAction
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly DivisionCacheService $cacheService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function run(Division $division, User $worker): void
|
||||||
|
{
|
||||||
|
$division->workers()->detach($worker->id);
|
||||||
|
$this->cacheService->clearAllCacheByModel();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Containers\Dashboard\Actions\Divisions;
|
||||||
|
|
||||||
|
use App\Containers\InstituteStructure\Models\Division;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
|
||||||
|
class ListDivisionWorkersAction
|
||||||
|
{
|
||||||
|
public function run(Division $division, array $filters = []): array
|
||||||
|
{
|
||||||
|
$query = $division->workers()
|
||||||
|
->withPivot(['administrativePosition', 'sort', 'service_email', 'service_phone', 'cabinet'])
|
||||||
|
->whereHas('userDetail');
|
||||||
|
|
||||||
|
// Поиск по ФИО
|
||||||
|
if (!empty($filters['search'])) {
|
||||||
|
$query->where('name', 'like', '%' . $filters['search'] . '%');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Фильтр по должности
|
||||||
|
if (!empty($filters['position'])) {
|
||||||
|
$query->where('division_user.administrativePosition', 'like', '%' . $filters['position'] . '%');
|
||||||
|
}
|
||||||
|
|
||||||
|
$workers = $query->orderBy('division_user.sort')->paginate(20)->withQueryString();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'workers' => $workers,
|
||||||
|
'filters' => $filters,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Containers\Dashboard\Actions\Divisions;
|
||||||
|
|
||||||
|
use App\Containers\InstituteStructure\Models\Division;
|
||||||
|
|
||||||
|
class ListDivisionsAction
|
||||||
|
{
|
||||||
|
public function run(array $filters = []): array
|
||||||
|
{
|
||||||
|
$query = Division::query();
|
||||||
|
|
||||||
|
// Фильтр по статусу (по умолчанию только активные)
|
||||||
|
if (isset($filters['is_active']) && $filters['is_active'] !== '') {
|
||||||
|
$query->where('is_active', (bool) $filters['is_active']);
|
||||||
|
} elseif (!isset($filters['is_active'])) {
|
||||||
|
$query->where('is_active', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Поиск по названию
|
||||||
|
if (!empty($filters['search'])) {
|
||||||
|
$query->where('title', 'like', '%' . $filters['search'] . '%')
|
||||||
|
->orWhere('slug', 'like', '%' . $filters['search'] . '%');
|
||||||
|
}
|
||||||
|
|
||||||
|
$divisions = $query->orderBy('created_at', 'desc')->paginate(20)->withQueryString();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'divisions' => $divisions,
|
||||||
|
'filters' => $filters,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Containers\Dashboard\Actions\Divisions;
|
||||||
|
|
||||||
|
use App\Containers\Dashboard\Tasks\Content\GenerateSearchDataTask;
|
||||||
|
use App\Containers\InstituteStructure\Models\Division;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class UpdateDivisionAction
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly GenerateSearchDataTask $generateSearchDataTask,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function run(Division $division, array $data): Division
|
||||||
|
{
|
||||||
|
// Генерируем search_data из описания только если описание передано и не пусто
|
||||||
|
if (isset($data['description']) && !empty($data['description'])) {
|
||||||
|
$data['search_data'] = $this->generateSearchDataTask->run($data['description']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$division->update($data);
|
||||||
|
|
||||||
|
// Обновляем SEO
|
||||||
|
$this->updateSeo($division, $data);
|
||||||
|
|
||||||
|
return $division;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function updateSeo(Division $division, array $data): void
|
||||||
|
{
|
||||||
|
$title = $data['title'] ?? $division->title;
|
||||||
|
$description = null;
|
||||||
|
|
||||||
|
// Извлекаем description из первого paragraph блока
|
||||||
|
if (!empty($data['description'])) {
|
||||||
|
foreach ($data['description'] as $block) {
|
||||||
|
if ($block['type'] === 'paragraph') {
|
||||||
|
$description = strip_tags($block['data']['content']);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$seoData = [
|
||||||
|
'title' => $title,
|
||||||
|
'description' => Str::limit(htmlspecialchars($description ?? '', ENT_QUOTES, 'UTF-8'), 160),
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($division->seo) {
|
||||||
|
$division->seo->update($seoData);
|
||||||
|
} else {
|
||||||
|
$division->seo()->create($seoData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Containers\Dashboard\Actions\Divisions;
|
||||||
|
|
||||||
|
use App\Containers\InstituteStructure\Models\Division;
|
||||||
|
use App\Containers\User\Models\User;
|
||||||
|
use App\Services\App\Cache\DivisionCacheService;
|
||||||
|
|
||||||
|
class UpdateDivisionWorkerAction
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly DivisionCacheService $cacheService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function run(Division $division, User $worker, array $data): void
|
||||||
|
{
|
||||||
|
$division->workers()->updateExistingPivot($worker->id, [
|
||||||
|
'administrativePosition' => $data['administrativePosition'],
|
||||||
|
'service_email' => $data['service_email'] ?? null,
|
||||||
|
'service_phone' => $data['service_phone'] ?? null,
|
||||||
|
'cabinet' => $data['cabinet'] ?? null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->cacheService->clearAllCacheByModel();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Containers\Dashboard\Actions\EducationalGroups;
|
||||||
|
|
||||||
|
use App\Containers\Schedule\Models\EducationalGroup;
|
||||||
|
|
||||||
|
class CreateEducationalGroupAction
|
||||||
|
{
|
||||||
|
public function run(array $data): EducationalGroup
|
||||||
|
{
|
||||||
|
return EducationalGroup::create($data);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Containers\Dashboard\Actions\EducationalGroups;
|
||||||
|
|
||||||
|
use App\Containers\Schedule\Models\EducationalGroup;
|
||||||
|
|
||||||
|
class DeleteEducationalGroupAction
|
||||||
|
{
|
||||||
|
public function run(EducationalGroup $group): bool
|
||||||
|
{
|
||||||
|
return $group->delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Containers\Dashboard\Actions\EducationalGroups;
|
||||||
|
|
||||||
|
use App\Containers\Schedule\Models\EducationalGroup;
|
||||||
|
use App\Ship\Enums\Education\FormEducation;
|
||||||
|
use App\Containers\InstituteStructure\Models\Faculty;
|
||||||
|
|
||||||
|
class ListEducationalGroupsAction
|
||||||
|
{
|
||||||
|
public function run(array $filters = []): array
|
||||||
|
{
|
||||||
|
$query = EducationalGroup::with(['faculty']);
|
||||||
|
|
||||||
|
// Фильтр по факультету
|
||||||
|
if (!empty($filters['faculty_id'])) {
|
||||||
|
$query->where('faculty_id', $filters['faculty_id']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Фильтр по форме обучения
|
||||||
|
if (!empty($filters['education_form_id'])) {
|
||||||
|
$query->where('education_form_id', $filters['education_form_id']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Поиск по названию группы
|
||||||
|
if (!empty($filters['search'])) {
|
||||||
|
$query->where('title', 'like', '%' . $filters['search'] . '%');
|
||||||
|
}
|
||||||
|
|
||||||
|
$groups = $query->orderBy('title')->paginate(20)->withQueryString();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'groups' => $groups,
|
||||||
|
'filters' => $filters,
|
||||||
|
'faculties' => Faculty::orderBy('title')->get(['id', 'title']),
|
||||||
|
'educationForms' => array_map(fn($form) => [
|
||||||
|
'value' => $form->value,
|
||||||
|
'label' => $form->getLabel(),
|
||||||
|
'color' => $form->getColor(),
|
||||||
|
'name' => $form->name,
|
||||||
|
], FormEducation::cases()),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Containers\Dashboard\Actions\EducationalGroups;
|
||||||
|
|
||||||
|
use App\Containers\Schedule\Models\EducationalGroup;
|
||||||
|
|
||||||
|
class UpdateEducationalGroupAction
|
||||||
|
{
|
||||||
|
public function run(EducationalGroup $group, array $data): EducationalGroup
|
||||||
|
{
|
||||||
|
$group->update($data);
|
||||||
|
return $group->fresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Containers\Dashboard\Actions\EducationalPrograms;
|
||||||
|
|
||||||
|
use App\Containers\Education\Models\EducationalProgram;
|
||||||
|
|
||||||
|
class CreateEducationalProgramAction
|
||||||
|
{
|
||||||
|
public function run(array $data): EducationalProgram
|
||||||
|
{
|
||||||
|
return EducationalProgram::create($data);
|
||||||
|
}
|
||||||
|
}
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Containers\Dashboard\Actions\EducationalPrograms;
|
||||||
|
|
||||||
|
use App\Containers\Education\Models\EducationalProgram;
|
||||||
|
|
||||||
|
class DeleteEducationalProgramAction
|
||||||
|
{
|
||||||
|
public function run(EducationalProgram $program): bool
|
||||||
|
{
|
||||||
|
return $program->delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
+55
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Containers\Dashboard\Actions\EducationalPrograms;
|
||||||
|
|
||||||
|
use App\Containers\Education\Models\EducationalProgram;
|
||||||
|
use App\Containers\Education\Models\DirectionStudy;
|
||||||
|
use App\Ship\Enums\Education\EducationalProgramStatus;
|
||||||
|
use App\Ship\Enums\Education\LevelEducational;
|
||||||
|
|
||||||
|
class ListEducationalProgramsAction
|
||||||
|
{
|
||||||
|
public function run(array $filters = []): array
|
||||||
|
{
|
||||||
|
$query = EducationalProgram::with(['directionStudy']);
|
||||||
|
|
||||||
|
// Фильтр по уровню образования
|
||||||
|
if (!empty($filters['lvl_edu'])) {
|
||||||
|
$query->where('lvl_edu', $filters['lvl_edu']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Фильтр по статусу
|
||||||
|
if (!empty($filters['status'])) {
|
||||||
|
$query->where('status', $filters['status']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Фильтр по направлению подготовки
|
||||||
|
if (!empty($filters['direction_study_id'])) {
|
||||||
|
$query->where('direction_study_id', $filters['direction_study_id']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Поиск по названию
|
||||||
|
if (!empty($filters['search'])) {
|
||||||
|
$search = $filters['search'];
|
||||||
|
$query->where('name', 'like', '%' . $search . '%');
|
||||||
|
}
|
||||||
|
|
||||||
|
$programs = $query->orderBy('name')->paginate(20)->withQueryString();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'programs' => $programs,
|
||||||
|
'filters' => $filters,
|
||||||
|
'statuses' => array_map(fn($status) => [
|
||||||
|
'value' => $status->value,
|
||||||
|
'label' => $status->getLabel(),
|
||||||
|
'color' => $status->getColor(),
|
||||||
|
], EducationalProgramStatus::cases()),
|
||||||
|
'educationLevels' => array_map(fn($level) => [
|
||||||
|
'value' => $level->value,
|
||||||
|
'label' => $level->getLabel(),
|
||||||
|
'color' => $level->getColor(),
|
||||||
|
], LevelEducational::cases()),
|
||||||
|
'directionStudies' => DirectionStudy::orderBy('code')->get(['id', 'code', 'name']),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Containers\Dashboard\Actions\EducationalPrograms;
|
||||||
|
|
||||||
|
use App\Containers\Education\Models\EducationalProgram;
|
||||||
|
|
||||||
|
class UpdateEducationalProgramAction
|
||||||
|
{
|
||||||
|
public function run(EducationalProgram $program, array $data): EducationalProgram
|
||||||
|
{
|
||||||
|
$program->update($data);
|
||||||
|
return $program;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -189,6 +189,9 @@ class FetchEmailNewsAction
|
|||||||
'subject' => $email['subject'] ?? 'unknown',
|
'subject' => $email['subject'] ?? 'unknown',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Помечаем письмо как прочитанное чтобы не обрабатывать повторно
|
||||||
|
$this->markEmail($email['message'], $folder);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'success' => false,
|
'success' => false,
|
||||||
'error' => 'Нет DOC/DOCX файла для извлечения текста',
|
'error' => 'Нет DOC/DOCX файла для извлечения текста',
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Containers\Dashboard\Actions\Faculties;
|
||||||
|
|
||||||
|
use App\Containers\InstituteStructure\Models\Faculty;
|
||||||
|
use App\Containers\User\Models\User;
|
||||||
|
use App\Services\App\Cache\FacultyCacheService;
|
||||||
|
|
||||||
|
class AttachFacultyWorkerAction
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly FacultyCacheService $cacheService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function run(Faculty $faculty, User $user, array $data): void
|
||||||
|
{
|
||||||
|
$faculty->workers()->attach($user->id, [
|
||||||
|
'position' => $data['position'],
|
||||||
|
'service_email' => $data['service_email'] ?? null,
|
||||||
|
'service_phone' => $data['service_phone'] ?? null,
|
||||||
|
'cabinet' => $data['cabinet'] ?? null,
|
||||||
|
'sort' => $data['sort'] ?? $faculty->workers()->count(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->cacheService->clearAllCacheByModel();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Containers\Dashboard\Actions\Faculties;
|
||||||
|
|
||||||
|
use App\Containers\Dashboard\Tasks\Content\GenerateSearchDataTask;
|
||||||
|
use App\Containers\InstituteStructure\Models\Faculty;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class CreateFacultyAction
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly GenerateSearchDataTask $generateSearchDataTask,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function run(array $data): Faculty
|
||||||
|
{
|
||||||
|
// Генерируем slug если не передан
|
||||||
|
if (empty($data['slug'])) {
|
||||||
|
$data['slug'] = Str::slug($data['title']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Генерируем search_data из контента
|
||||||
|
if (!empty($data['content'])) {
|
||||||
|
$data['search_data'] = $this->generateSearchDataTask->run($data['content']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$faculty = Faculty::create($data);
|
||||||
|
|
||||||
|
// Создаем SEO
|
||||||
|
$this->generateSeo($faculty, $data);
|
||||||
|
|
||||||
|
return $faculty;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function generateSeo(Faculty $faculty, array $data): void
|
||||||
|
{
|
||||||
|
$title = $data['title'] ?? '';
|
||||||
|
$description = null;
|
||||||
|
|
||||||
|
// Извлекаем description из первого paragraph блока
|
||||||
|
if (!empty($data['content'])) {
|
||||||
|
foreach ($data['content'] as $block) {
|
||||||
|
if ($block['type'] === 'paragraph') {
|
||||||
|
$description = strip_tags($block['data']['content']);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$faculty->seo()->create([
|
||||||
|
'title' => $title,
|
||||||
|
'description' => Str::limit(htmlspecialchars($description ?? '', ENT_QUOTES, 'UTF-8'), 160),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Containers\Dashboard\Actions\Faculties;
|
||||||
|
|
||||||
|
use App\Containers\InstituteStructure\Models\Faculty;
|
||||||
|
|
||||||
|
class DeleteFacultyAction
|
||||||
|
{
|
||||||
|
public function run(Faculty $faculty): void
|
||||||
|
{
|
||||||
|
$faculty->delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Containers\Dashboard\Actions\Faculties;
|
||||||
|
|
||||||
|
use App\Containers\InstituteStructure\Models\Faculty;
|
||||||
|
use App\Containers\User\Models\User;
|
||||||
|
use App\Services\App\Cache\FacultyCacheService;
|
||||||
|
|
||||||
|
class DetachFacultyWorkerAction
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly FacultyCacheService $cacheService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function run(Faculty $faculty, User $worker): void
|
||||||
|
{
|
||||||
|
$faculty->workers()->detach($worker->id);
|
||||||
|
$this->cacheService->clearAllCacheByModel();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Containers\Dashboard\Actions\Faculties;
|
||||||
|
|
||||||
|
use App\Containers\InstituteStructure\Models\Faculty;
|
||||||
|
|
||||||
|
class ListFacultiesAction
|
||||||
|
{
|
||||||
|
public function run(array $filters = []): array
|
||||||
|
{
|
||||||
|
$query = Faculty::query();
|
||||||
|
|
||||||
|
// Фильтр по статусу
|
||||||
|
if (isset($filters['is_active']) && $filters['is_active'] !== '') {
|
||||||
|
$query->where('is_active', (bool) $filters['is_active']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Поиск по названию
|
||||||
|
if (!empty($filters['search'])) {
|
||||||
|
$query->where('title', 'like', '%' . $filters['search'] . '%');
|
||||||
|
}
|
||||||
|
|
||||||
|
$faculties = $query->orderBy('title')->paginate(20)->withQueryString();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'faculties' => $faculties,
|
||||||
|
'filters' => $filters,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Containers\Dashboard\Actions\Faculties;
|
||||||
|
|
||||||
|
use App\Containers\InstituteStructure\Models\Faculty;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
|
||||||
|
class ListFacultyWorkersAction
|
||||||
|
{
|
||||||
|
public function run(Faculty $faculty, array $filters = []): array
|
||||||
|
{
|
||||||
|
$query = $faculty->workers()
|
||||||
|
->withPivot(['position', 'sort', 'service_email', 'service_phone', 'cabinet'])
|
||||||
|
->whereHas('userDetail');
|
||||||
|
|
||||||
|
// Поиск по ФИО
|
||||||
|
if (!empty($filters['search'])) {
|
||||||
|
$query->where('name', 'like', '%' . $filters['search'] . '%');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Фильтр по должности
|
||||||
|
if (!empty($filters['position'])) {
|
||||||
|
$query->where('workers_faculties.position', 'like', '%' . $filters['position'] . '%');
|
||||||
|
}
|
||||||
|
|
||||||
|
$workers = $query->orderBy('workers_faculties.sort')->paginate(20)->withQueryString();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'workers' => $workers,
|
||||||
|
'filters' => $filters,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Containers\Dashboard\Actions\Faculties;
|
||||||
|
|
||||||
|
use App\Containers\Dashboard\Tasks\Content\GenerateSearchDataTask;
|
||||||
|
use App\Containers\InstituteStructure\Models\Faculty;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class UpdateFacultyAction
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly GenerateSearchDataTask $generateSearchDataTask,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function run(Faculty $faculty, array $data): Faculty
|
||||||
|
{
|
||||||
|
// Генерируем search_data из контента только если контент передан и не пуст
|
||||||
|
if (isset($data['content']) && !empty($data['content'])) {
|
||||||
|
$data['search_data'] = $this->generateSearchDataTask->run($data['content']);
|
||||||
|
}
|
||||||
|
|
||||||
|
$faculty->update($data);
|
||||||
|
|
||||||
|
// Обновляем SEO
|
||||||
|
$this->updateSeo($faculty, $data);
|
||||||
|
|
||||||
|
return $faculty;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function updateSeo(Faculty $faculty, array $data): void
|
||||||
|
{
|
||||||
|
$title = $data['title'] ?? $faculty->title;
|
||||||
|
$description = null;
|
||||||
|
|
||||||
|
// Извлекаем description из первого paragraph блока
|
||||||
|
if (!empty($data['content'])) {
|
||||||
|
foreach ($data['content'] as $block) {
|
||||||
|
if ($block['type'] === 'paragraph') {
|
||||||
|
$description = strip_tags($block['data']['content']);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$seoData = [
|
||||||
|
'title' => $title,
|
||||||
|
'description' => Str::limit(htmlspecialchars($description ?? '', ENT_QUOTES, 'UTF-8'), 160),
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($faculty->seo) {
|
||||||
|
$faculty->seo->update($seoData);
|
||||||
|
} else {
|
||||||
|
$faculty->seo()->create($seoData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Containers\Dashboard\Actions\Faculties;
|
||||||
|
|
||||||
|
use App\Containers\InstituteStructure\Models\Faculty;
|
||||||
|
use App\Containers\User\Models\User;
|
||||||
|
use App\Services\App\Cache\FacultyCacheService;
|
||||||
|
|
||||||
|
class UpdateFacultyWorkerAction
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly FacultyCacheService $cacheService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function run(Faculty $faculty, User $worker, array $data): void
|
||||||
|
{
|
||||||
|
$faculty->workers()->updateExistingPivot($worker->id, [
|
||||||
|
'position' => $data['position'],
|
||||||
|
'service_email' => $data['service_email'] ?? null,
|
||||||
|
'service_phone' => $data['service_phone'] ?? null,
|
||||||
|
'cabinet' => $data['cabinet'] ?? null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->cacheService->clearAllCacheByModel();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,234 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Containers\Dashboard\Actions;
|
|
||||||
|
|
||||||
use App\Containers\Dashboard\Data\EmailAttachmentData;
|
|
||||||
use App\Containers\Dashboard\Exceptions\EmailFetchException;
|
|
||||||
use App\Containers\Dashboard\Tasks\ConnectToImapTask;
|
|
||||||
use App\Containers\Dashboard\Tasks\DownloadAttachmentsTask;
|
|
||||||
use App\Containers\Dashboard\Tasks\FetchUnreadEmailsTask;
|
|
||||||
use App\Containers\Dashboard\Tasks\FilterBySenderTask;
|
|
||||||
use App\Containers\Dashboard\Tasks\MarkEmailAsReadTask;
|
|
||||||
use App\Containers\Dashboard\Actions\ProcessMixedFilesAction;
|
|
||||||
use Illuminate\Http\UploadedFile;
|
|
||||||
use Illuminate\Support\Facades\Log;
|
|
||||||
use Illuminate\Support\Facades\Storage;
|
|
||||||
use Webklex\PHPIMAP\Client;
|
|
||||||
use Webklex\PHPIMAP\Folder;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Оркестрация процесса получения новостей из Email
|
|
||||||
*/
|
|
||||||
class FetchEmailNewsAction
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private readonly ConnectToImapTask $connectToImapTask,
|
|
||||||
private readonly FetchUnreadEmailsTask $fetchUnreadEmailsTask,
|
|
||||||
private readonly FilterBySenderTask $filterBySenderTask,
|
|
||||||
private readonly DownloadAttachmentsTask $downloadAttachmentsTask,
|
|
||||||
private readonly MarkEmailAsReadTask $markEmailAsReadTask,
|
|
||||||
private readonly ProcessMixedFilesAction $processMixedFilesAction,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Выполнить получение и обработку новостей из Email
|
|
||||||
*
|
|
||||||
* @return array Результат обработки
|
|
||||||
* @throws EmailFetchException
|
|
||||||
*/
|
|
||||||
public function run(): array
|
|
||||||
{
|
|
||||||
// Проверяем, включена ли функция
|
|
||||||
if (!config('email-news.enabled', true)) {
|
|
||||||
Log::warning('[FetchEmailNewsAction] Функция отключена в конфиге');
|
|
||||||
throw EmailFetchException::featureDisabled();
|
|
||||||
}
|
|
||||||
|
|
||||||
Log::info('[FetchEmailNewsAction] Начало получения новостей из Email');
|
|
||||||
|
|
||||||
$result = [
|
|
||||||
'processed_emails' => 0,
|
|
||||||
'skipped_emails' => 0,
|
|
||||||
'created_posts' => 0,
|
|
||||||
'errors' => [],
|
|
||||||
'posts' => [],
|
|
||||||
];
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Подключаемся к IMAP
|
|
||||||
$client = $this->connectToImapTask->run();
|
|
||||||
|
|
||||||
// Получаем папку
|
|
||||||
$folder = $this->connectToImapTask->getFolder(
|
|
||||||
$client,
|
|
||||||
config('email-news.folder', 'INBOX')
|
|
||||||
);
|
|
||||||
|
|
||||||
// Получаем непрочитанные письма
|
|
||||||
$emails = $this->fetchUnreadEmailsTask->run($folder);
|
|
||||||
|
|
||||||
if (empty($emails)) {
|
|
||||||
Log::info('[FetchEmailNewsAction] Нет непрочитанных писем');
|
|
||||||
return $result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Фильтруем по отправителю
|
|
||||||
$filteredEmails = $this->filterBySenderTask->run($emails);
|
|
||||||
|
|
||||||
$result['skipped_emails'] = count($emails) - count($filteredEmails);
|
|
||||||
|
|
||||||
// Обрабатываем каждое письмо
|
|
||||||
foreach ($filteredEmails as $email) {
|
|
||||||
$emailResult = $this->processEmail($email, $folder);
|
|
||||||
|
|
||||||
if ($emailResult['success']) {
|
|
||||||
$result['created_posts']++;
|
|
||||||
$result['posts'][] = $emailResult['post'];
|
|
||||||
} else {
|
|
||||||
$result['errors'][] = [
|
|
||||||
'email_subject' => $email['subject'],
|
|
||||||
'error' => $emailResult['error'],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$result['processed_emails']++;
|
|
||||||
}
|
|
||||||
|
|
||||||
Log::info('[FetchEmailNewsAction] Завершено', [
|
|
||||||
'processed' => $result['processed_emails'],
|
|
||||||
'created_posts' => $result['created_posts'],
|
|
||||||
'skipped' => $result['skipped_emails'],
|
|
||||||
'errors_count' => count($result['errors']),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return $result;
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
Log::error('[FetchEmailNewsAction] Критическая ошибка', [
|
|
||||||
'error' => $e->getMessage(),
|
|
||||||
'trace' => $e->getTraceAsString(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
throw $e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Обработать одно письмо
|
|
||||||
*
|
|
||||||
* @param array $email Данные письма
|
|
||||||
* @param Folder $folder IMAP папка
|
|
||||||
* @return array Результат обработки
|
|
||||||
*/
|
|
||||||
private function processEmail(array $email, Folder $folder): array
|
|
||||||
{
|
|
||||||
Log::info('[FetchEmailNewsAction:processEmail] Обработка письма', [
|
|
||||||
'subject' => $email['subject'],
|
|
||||||
'from' => $email['from_email'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Скачиваем вложения
|
|
||||||
$attachments = $this->downloadAttachmentsTask->run($email['message']);
|
|
||||||
|
|
||||||
// Проверяем, есть ли DOC/DOCX файл
|
|
||||||
$hasDocument = collect($attachments)->contains(fn($att) => $att->isDocument());
|
|
||||||
|
|
||||||
if (!$hasDocument) {
|
|
||||||
Log::warning('[FetchEmailNewsAction:processEmail] Нет DOC/DOCX файла во вложениях', [
|
|
||||||
'subject' => $email['subject'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'success' => false,
|
|
||||||
'error' => 'Нет DOC/DOCX файла для извлечения текста',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Конвертируем вложения в UploadedFile
|
|
||||||
$uploadedFiles = $this->convertToUploadedFiles($attachments);
|
|
||||||
|
|
||||||
// Обрабатываем через существующий ProcessMixedFilesAction
|
|
||||||
$postResult = $this->processMixedFilesAction->run($uploadedFiles);
|
|
||||||
|
|
||||||
// Помечаем письмо как прочитанное
|
|
||||||
$this->markEmail($email['message'], $folder);
|
|
||||||
|
|
||||||
Log::info('[FetchEmailNewsAction:processEmail] Письмо успешно обработано', [
|
|
||||||
'subject' => $email['subject'],
|
|
||||||
'post_id' => $postResult['post']->id,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'success' => true,
|
|
||||||
'post' => $postResult['post'],
|
|
||||||
'attachments_count' => count($attachments),
|
|
||||||
];
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
Log::error('[FetchEmailNewsAction:processEmail] Ошибка обработки письма', [
|
|
||||||
'subject' => $email['subject'],
|
|
||||||
'error' => $e->getMessage(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'success' => false,
|
|
||||||
'error' => $e->getMessage(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Конвертировать EmailAttachmentData в UploadedFile
|
|
||||||
*
|
|
||||||
* @param array<EmailAttachmentData> $attachments
|
|
||||||
* @return \Illuminate\Support\Collection<UploadedFile>
|
|
||||||
*/
|
|
||||||
private function convertToUploadedFiles(array $attachments): \Illuminate\Support\Collection
|
|
||||||
{
|
|
||||||
$uploadedFiles = [];
|
|
||||||
|
|
||||||
foreach ($attachments as $attachment) {
|
|
||||||
$fullPath = storage_path('app/' . $attachment->path);
|
|
||||||
|
|
||||||
if (!file_exists($fullPath)) {
|
|
||||||
Log::warning('[FetchEmailNewsAction:convertToUploadedFiles] Файл не найден', [
|
|
||||||
'path' => $attachment->path,
|
|
||||||
]);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Создаём UploadedFile из сохранённого файла
|
|
||||||
$uploadedFile = new UploadedFile(
|
|
||||||
$fullPath,
|
|
||||||
$attachment->filename,
|
|
||||||
$attachment->mimeType,
|
|
||||||
null,
|
|
||||||
true // test = false (файл валиден)
|
|
||||||
);
|
|
||||||
|
|
||||||
$uploadedFiles[] = $uploadedFile;
|
|
||||||
}
|
|
||||||
|
|
||||||
Log::info('[FetchEmailNewsAction:convertToUploadedFiles] Конвертировано файлов', [
|
|
||||||
'count' => count($uploadedFiles),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return collect($uploadedFiles);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Пометить письмо как прочитанное (и возможно переместить)
|
|
||||||
*
|
|
||||||
* @param object $message IMAP сообщение
|
|
||||||
* @param Folder $folder Текущая папка
|
|
||||||
*/
|
|
||||||
private function markEmail(object $message, Folder $folder): void
|
|
||||||
{
|
|
||||||
$moveToFolder = config('email-news.move_to_folder');
|
|
||||||
|
|
||||||
if ($moveToFolder) {
|
|
||||||
$this->markEmailAsReadTask->markAndMove($message, $moveToFolder);
|
|
||||||
} elseif (config('email-news.mark_as_read', true)) {
|
|
||||||
$this->markEmailAsReadTask->run($message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Containers\Dashboard\Actions\JournalIssues;
|
||||||
|
|
||||||
|
use App\Containers\Science\Models\JournalIssue;
|
||||||
|
|
||||||
|
class CreateJournalIssueAction
|
||||||
|
{
|
||||||
|
public function run(array $data): JournalIssue
|
||||||
|
{
|
||||||
|
return JournalIssue::create($data);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Containers\Dashboard\Actions\JournalIssues;
|
||||||
|
|
||||||
|
use App\Containers\Science\Models\JournalIssue;
|
||||||
|
|
||||||
|
class DeleteJournalIssueAction
|
||||||
|
{
|
||||||
|
public function run(JournalIssue $issue): bool
|
||||||
|
{
|
||||||
|
return $issue->delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Containers\Dashboard\Actions\JournalIssues;
|
||||||
|
|
||||||
|
use App\Containers\Science\Models\JournalIssue;
|
||||||
|
|
||||||
|
class ListJournalIssuesAction
|
||||||
|
{
|
||||||
|
public function run(int $journalId, array $filters = []): array
|
||||||
|
{
|
||||||
|
$query = JournalIssue::where('academic_journal_id', $journalId);
|
||||||
|
|
||||||
|
// Фильтр по году
|
||||||
|
if (!empty($filters['year_publication'])) {
|
||||||
|
$query->where('year_publication', $filters['year_publication']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Фильтр по статусу
|
||||||
|
if (isset($filters['is_active']) && $filters['is_active'] !== '') {
|
||||||
|
$query->where('is_active', (bool) $filters['is_active']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Поиск по названию
|
||||||
|
if (!empty($filters['search'])) {
|
||||||
|
$query->where('title', 'like', '%' . $filters['search'] . '%');
|
||||||
|
}
|
||||||
|
|
||||||
|
$issues = $query->orderBy('sort')->orderBy('year_publication', 'desc')->paginate(20)->withQueryString();
|
||||||
|
|
||||||
|
// Получаем уникальные годы для фильтра (оптимизировано через groupBy)
|
||||||
|
$years = JournalIssue::where('academic_journal_id', $journalId)
|
||||||
|
->groupBy('year_publication')
|
||||||
|
->orderBy('year_publication', 'desc')
|
||||||
|
->pluck('year_publication');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'issues' => $issues,
|
||||||
|
'filters' => $filters,
|
||||||
|
'years' => $years,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Containers\Dashboard\Actions\JournalIssues;
|
||||||
|
|
||||||
|
use App\Containers\Science\Models\JournalIssue;
|
||||||
|
|
||||||
|
class UpdateJournalIssueAction
|
||||||
|
{
|
||||||
|
public function run(JournalIssue $issue, array $data): JournalIssue
|
||||||
|
{
|
||||||
|
$issue->update($data);
|
||||||
|
return $issue->fresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Containers\Dashboard\Actions;
|
||||||
|
|
||||||
|
use App\Containers\Dashboard\Tasks\Posts\GetAiPreparedPostsTask;
|
||||||
|
use App\Containers\Dashboard\Tasks\Stats\GetDashboardStatsTask;
|
||||||
|
use App\Containers\Dashboard\Tasks\Stats\GetRecentActivityTask;
|
||||||
|
|
||||||
|
class LoadDashboardDataAction
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly GetAiPreparedPostsTask $getAiPreparedPostsTask,
|
||||||
|
private readonly GetDashboardStatsTask $getDashboardStatsTask,
|
||||||
|
private readonly GetRecentActivityTask $getRecentActivityTask,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function run(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'aiPreparedPosts' => $this->getAiPreparedPostsTask->run(),
|
||||||
|
'stats' => $this->getDashboardStatsTask->run(),
|
||||||
|
'recentActivity' => $this->getRecentActivityTask->run(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Containers\Dashboard\Actions\MainSections;
|
||||||
|
|
||||||
|
use App\Containers\AppStructure\Models\MainSection;
|
||||||
|
|
||||||
|
class CreateMainSectionAction
|
||||||
|
{
|
||||||
|
public function run(array $data): MainSection
|
||||||
|
{
|
||||||
|
return MainSection::create([
|
||||||
|
'title' => $data['title'],
|
||||||
|
'slug' => $data['slug'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Containers\Dashboard\Actions\MainSections;
|
||||||
|
|
||||||
|
use App\Containers\AppStructure\Models\MainSection;
|
||||||
|
|
||||||
|
class DeleteMainSectionAction
|
||||||
|
{
|
||||||
|
public function run(MainSection $mainSection): bool
|
||||||
|
{
|
||||||
|
return $mainSection->delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user