diff --git a/.gitignore b/.gitignore index e4e679f..d969777 100755 --- a/.gitignore +++ b/.gitignore @@ -13,5 +13,6 @@ dump.sql .DS_Store **/. .cursor -hot +.cache QWEN.md +hot diff --git a/.qwen/settings.json b/.qwen/settings.json new file mode 100644 index 0000000..77aea60 --- /dev/null +++ b/.qwen/settings.json @@ -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 +} \ No newline at end of file diff --git a/.qwen/settings.json.orig b/.qwen/settings.json.orig new file mode 100644 index 0000000..9a9e4cc --- /dev/null +++ b/.qwen/settings.json.orig @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "Bash(mkdir *)" + ] + } +} \ No newline at end of file diff --git a/.qwen/skills/admin-design/SKILL.md b/.qwen/skills/admin-design/SKILL.md new file mode 100644 index 0000000..7d64f92 --- /dev/null +++ b/.qwen/skills/admin-design/SKILL.md @@ -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`. \ No newline at end of file diff --git a/.qwen/skills/ui-ux-pro-max/SKILL.md b/.qwen/skills/ui-ux-pro-max/SKILL.md new file mode 100644 index 0000000..a64d6c2 --- /dev/null +++ b/.qwen/skills/ui-ux-pro-max/SKILL.md @@ -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 ` 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 " " --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 "" --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 "" --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 "" --domain [-n ] +``` + +**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 "" --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 \ No newline at end of file diff --git a/.qwen/skills/ui-ux-pro-max/data b/.qwen/skills/ui-ux-pro-max/data new file mode 100644 index 0000000..e5b9469 --- /dev/null +++ b/.qwen/skills/ui-ux-pro-max/data @@ -0,0 +1 @@ +../../../src/ui-ux-pro-max/data \ No newline at end of file diff --git a/.qwen/skills/ui-ux-pro-max/scripts b/.qwen/skills/ui-ux-pro-max/scripts new file mode 100644 index 0000000..ccb93f7 --- /dev/null +++ b/.qwen/skills/ui-ux-pro-max/scripts @@ -0,0 +1 @@ +../../../src/ui-ux-pro-max/scripts \ No newline at end of file diff --git a/app/Containers/Article/UI/WEB/Routes/web.php b/app/Containers/Article/UI/WEB/Routes/web.php index e176c69..c773cd7 100755 --- a/app/Containers/Article/UI/WEB/Routes/web.php +++ b/app/Containers/Article/UI/WEB/Routes/web.php @@ -9,6 +9,3 @@ Route::middleware('access-check')->group(function () { Route::get('/news', IndexPostController::class)->name('client.post.index'); Route::get('/news/{slug}', ShowPostController::class)->name('client.post.show'); }); - - - diff --git "a/app/Containers/Article/UI/WEB\\Requests/CreateSlideRequest.php" "b/app/Containers/Article/UI/WEB\\Requests/CreateSlideRequest.php" new file mode 100644 index 0000000..5425734 --- /dev/null +++ "b/app/Containers/Article/UI/WEB\\Requests/CreateSlideRequest.php" @@ -0,0 +1,32 @@ + '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', + ]; + } +} diff --git "a/app/Containers/Article/UI/WEB\\Requests/UpdateSlideRequest.php" "b/app/Containers/Article/UI/WEB\\Requests/UpdateSlideRequest.php" new file mode 100644 index 0000000..66cf991 --- /dev/null +++ "b/app/Containers/Article/UI/WEB\\Requests/UpdateSlideRequest.php" @@ -0,0 +1,32 @@ + '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', + ]; + } +} diff --git a/app/Containers/Dashboard/Actions/AcademicJournals/CreateAcademicJournalAction.php b/app/Containers/Dashboard/Actions/AcademicJournals/CreateAcademicJournalAction.php new file mode 100644 index 0000000..db39f2d --- /dev/null +++ b/app/Containers/Dashboard/Actions/AcademicJournals/CreateAcademicJournalAction.php @@ -0,0 +1,13 @@ +delete(); + } +} diff --git a/app/Containers/Dashboard/Actions/AcademicJournals/ListAcademicJournalsAction.php b/app/Containers/Dashboard/Actions/AcademicJournals/ListAcademicJournalsAction.php new file mode 100644 index 0000000..f166e8a --- /dev/null +++ b/app/Containers/Dashboard/Actions/AcademicJournals/ListAcademicJournalsAction.php @@ -0,0 +1,25 @@ +where('title', 'like', '%' . $filters['search'] . '%'); + } + + $journals = $query->orderBy('created_at', 'desc')->paginate(20)->withQueryString(); + + return [ + 'journals' => $journals, + 'filters' => $filters, + ]; + } +} diff --git a/app/Containers/Dashboard/Actions/AcademicJournals/UpdateAcademicJournalAction.php b/app/Containers/Dashboard/Actions/AcademicJournals/UpdateAcademicJournalAction.php new file mode 100644 index 0000000..ad90097 --- /dev/null +++ b/app/Containers/Dashboard/Actions/AcademicJournals/UpdateAcademicJournalAction.php @@ -0,0 +1,14 @@ +update($data); + return $journal->fresh(); + } +} diff --git a/app/Containers/Dashboard/Actions/AdditionalEducations/Categories/CreateCategoryAction.php b/app/Containers/Dashboard/Actions/AdditionalEducations/Categories/CreateCategoryAction.php new file mode 100644 index 0000000..4329d4f --- /dev/null +++ b/app/Containers/Dashboard/Actions/AdditionalEducations/Categories/CreateCategoryAction.php @@ -0,0 +1,13 @@ +delete(); + } +} diff --git a/app/Containers/Dashboard/Actions/AdditionalEducations/Categories/ListCategoriesAction.php b/app/Containers/Dashboard/Actions/AdditionalEducations/Categories/ListCategoriesAction.php new file mode 100644 index 0000000..0795d6f --- /dev/null +++ b/app/Containers/Dashboard/Actions/AdditionalEducations/Categories/ListCategoriesAction.php @@ -0,0 +1,40 @@ +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']), + ]; + } +} diff --git a/app/Containers/Dashboard/Actions/AdditionalEducations/Categories/UpdateCategoryAction.php b/app/Containers/Dashboard/Actions/AdditionalEducations/Categories/UpdateCategoryAction.php new file mode 100644 index 0000000..15673a8 --- /dev/null +++ b/app/Containers/Dashboard/Actions/AdditionalEducations/Categories/UpdateCategoryAction.php @@ -0,0 +1,14 @@ +update($data); + return $category; + } +} diff --git a/app/Containers/Dashboard/Actions/AdditionalEducations/CreateAdditionalEducationAction.php b/app/Containers/Dashboard/Actions/AdditionalEducations/CreateAdditionalEducationAction.php new file mode 100644 index 0000000..946f6ea --- /dev/null +++ b/app/Containers/Dashboard/Actions/AdditionalEducations/CreateAdditionalEducationAction.php @@ -0,0 +1,20 @@ +generateSearchDataTask->run($data['content'] ?? []); + + return AdditionalEducation::create($data); + } +} diff --git a/app/Containers/Dashboard/Actions/AdditionalEducations/DeleteAdditionalEducationAction.php b/app/Containers/Dashboard/Actions/AdditionalEducations/DeleteAdditionalEducationAction.php new file mode 100644 index 0000000..a39a504 --- /dev/null +++ b/app/Containers/Dashboard/Actions/AdditionalEducations/DeleteAdditionalEducationAction.php @@ -0,0 +1,13 @@ +delete(); + } +} diff --git a/app/Containers/Dashboard/Actions/AdditionalEducations/Directions/CreateDirectionAction.php b/app/Containers/Dashboard/Actions/AdditionalEducations/Directions/CreateDirectionAction.php new file mode 100644 index 0000000..25021ae --- /dev/null +++ b/app/Containers/Dashboard/Actions/AdditionalEducations/Directions/CreateDirectionAction.php @@ -0,0 +1,13 @@ +delete(); + } +} diff --git a/app/Containers/Dashboard/Actions/AdditionalEducations/Directions/ListDirectionsAction.php b/app/Containers/Dashboard/Actions/AdditionalEducations/Directions/ListDirectionsAction.php new file mode 100644 index 0000000..0da640a --- /dev/null +++ b/app/Containers/Dashboard/Actions/AdditionalEducations/Directions/ListDirectionsAction.php @@ -0,0 +1,31 @@ +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, + ]; + } +} diff --git a/app/Containers/Dashboard/Actions/AdditionalEducations/Directions/UpdateDirectionAction.php b/app/Containers/Dashboard/Actions/AdditionalEducations/Directions/UpdateDirectionAction.php new file mode 100644 index 0000000..a41d5bf --- /dev/null +++ b/app/Containers/Dashboard/Actions/AdditionalEducations/Directions/UpdateDirectionAction.php @@ -0,0 +1,14 @@ +update($data); + return $direction; + } +} diff --git a/app/Containers/Dashboard/Actions/AdditionalEducations/ListAdditionalEducationsAction.php b/app/Containers/Dashboard/Actions/AdditionalEducations/ListAdditionalEducationsAction.php new file mode 100644 index 0000000..f4880c3 --- /dev/null +++ b/app/Containers/Dashboard/Actions/AdditionalEducations/ListAdditionalEducationsAction.php @@ -0,0 +1,55 @@ +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()), + ]; + } +} diff --git a/app/Containers/Dashboard/Actions/AdditionalEducations/UpdateAdditionalEducationAction.php b/app/Containers/Dashboard/Actions/AdditionalEducations/UpdateAdditionalEducationAction.php new file mode 100644 index 0000000..7c79811 --- /dev/null +++ b/app/Containers/Dashboard/Actions/AdditionalEducations/UpdateAdditionalEducationAction.php @@ -0,0 +1,24 @@ +generateSearchDataTask->run($data['content']); + } + + $education->update($data); + + return $education; + } +} diff --git a/app/Containers/Dashboard/Actions/AdmissionCampaigns/CreateAdmissionCampaignAction.php b/app/Containers/Dashboard/Actions/AdmissionCampaigns/CreateAdmissionCampaignAction.php new file mode 100644 index 0000000..f5fd26d --- /dev/null +++ b/app/Containers/Dashboard/Actions/AdmissionCampaigns/CreateAdmissionCampaignAction.php @@ -0,0 +1,13 @@ +delete(); + } +} diff --git a/app/Containers/Dashboard/Actions/AdmissionCampaigns/ListAdmissionCampaignsAction.php b/app/Containers/Dashboard/Actions/AdmissionCampaigns/ListAdmissionCampaignsAction.php new file mode 100644 index 0000000..941d92e --- /dev/null +++ b/app/Containers/Dashboard/Actions/AdmissionCampaigns/ListAdmissionCampaignsAction.php @@ -0,0 +1,58 @@ +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; + } +} diff --git a/app/Containers/Dashboard/Actions/AdmissionCampaigns/UpdateAdmissionCampaignAction.php b/app/Containers/Dashboard/Actions/AdmissionCampaigns/UpdateAdmissionCampaignAction.php new file mode 100644 index 0000000..14f3306 --- /dev/null +++ b/app/Containers/Dashboard/Actions/AdmissionCampaigns/UpdateAdmissionCampaignAction.php @@ -0,0 +1,14 @@ +update($data); + return $campaign; + } +} diff --git a/app/Containers/Dashboard/Actions/AdmissionPlans/CreateAdmissionPlanAction.php b/app/Containers/Dashboard/Actions/AdmissionPlans/CreateAdmissionPlanAction.php new file mode 100644 index 0000000..1f028b0 --- /dev/null +++ b/app/Containers/Dashboard/Actions/AdmissionPlans/CreateAdmissionPlanAction.php @@ -0,0 +1,13 @@ +delete(); + } +} diff --git a/app/Containers/Dashboard/Actions/AdmissionPlans/ListAdmissionPlansAction.php b/app/Containers/Dashboard/Actions/AdmissionPlans/ListAdmissionPlansAction.php new file mode 100644 index 0000000..1530755 --- /dev/null +++ b/app/Containers/Dashboard/Actions/AdmissionPlans/ListAdmissionPlansAction.php @@ -0,0 +1,38 @@ +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']), + ]; + } +} diff --git a/app/Containers/Dashboard/Actions/AdmissionPlans/UpdateAdmissionPlanAction.php b/app/Containers/Dashboard/Actions/AdmissionPlans/UpdateAdmissionPlanAction.php new file mode 100644 index 0000000..af4702c --- /dev/null +++ b/app/Containers/Dashboard/Actions/AdmissionPlans/UpdateAdmissionPlanAction.php @@ -0,0 +1,14 @@ +update($data); + return $plan; + } +} diff --git a/app/Containers/Dashboard/Actions/Categories/CreateCategoryAction.php b/app/Containers/Dashboard/Actions/Categories/CreateCategoryAction.php new file mode 100644 index 0000000..6b8eef1 --- /dev/null +++ b/app/Containers/Dashboard/Actions/Categories/CreateCategoryAction.php @@ -0,0 +1,16 @@ +delete(); + } +} diff --git a/app/Containers/Dashboard/Actions/Categories/ListCategoriesAction.php b/app/Containers/Dashboard/Actions/Categories/ListCategoriesAction.php new file mode 100644 index 0000000..ae62c7c --- /dev/null +++ b/app/Containers/Dashboard/Actions/Categories/ListCategoriesAction.php @@ -0,0 +1,30 @@ +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, + ]; + } +} diff --git a/app/Containers/Dashboard/Actions/Categories/UpdateCategoryAction.php b/app/Containers/Dashboard/Actions/Categories/UpdateCategoryAction.php new file mode 100644 index 0000000..05326bc --- /dev/null +++ b/app/Containers/Dashboard/Actions/Categories/UpdateCategoryAction.php @@ -0,0 +1,19 @@ +update($data); + return $category->fresh(); + } +} diff --git a/app/Containers/Dashboard/Actions/ContactWidgets/CreateContactWidgetAction.php b/app/Containers/Dashboard/Actions/ContactWidgets/CreateContactWidgetAction.php new file mode 100644 index 0000000..0934cbf --- /dev/null +++ b/app/Containers/Dashboard/Actions/ContactWidgets/CreateContactWidgetAction.php @@ -0,0 +1,17 @@ +delete(); + } +} diff --git a/app/Containers/Dashboard/Actions/ContactWidgets/ListContactWidgetsAction.php b/app/Containers/Dashboard/Actions/ContactWidgets/ListContactWidgetsAction.php new file mode 100644 index 0000000..4fa031d --- /dev/null +++ b/app/Containers/Dashboard/Actions/ContactWidgets/ListContactWidgetsAction.php @@ -0,0 +1,30 @@ +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, + ]; + } +} diff --git a/app/Containers/Dashboard/Actions/ContactWidgets/UpdateContactWidgetAction.php b/app/Containers/Dashboard/Actions/ContactWidgets/UpdateContactWidgetAction.php new file mode 100644 index 0000000..532a8d9 --- /dev/null +++ b/app/Containers/Dashboard/Actions/ContactWidgets/UpdateContactWidgetAction.php @@ -0,0 +1,15 @@ +update($data); + + return $widget; + } +} diff --git a/app/Containers/Dashboard/Actions/CustomForms/CreateCustomFormAction.php b/app/Containers/Dashboard/Actions/CustomForms/CreateCustomFormAction.php new file mode 100644 index 0000000..9786c17 --- /dev/null +++ b/app/Containers/Dashboard/Actions/CustomForms/CreateCustomFormAction.php @@ -0,0 +1,20 @@ +delete(); + } +} diff --git a/app/Containers/Dashboard/Actions/CustomForms/DeleteFormResponseAction.php b/app/Containers/Dashboard/Actions/CustomForms/DeleteFormResponseAction.php new file mode 100644 index 0000000..ada3c02 --- /dev/null +++ b/app/Containers/Dashboard/Actions/CustomForms/DeleteFormResponseAction.php @@ -0,0 +1,13 @@ +delete(); + } +} diff --git a/app/Containers/Dashboard/Actions/CustomForms/ListCustomFormsAction.php b/app/Containers/Dashboard/Actions/CustomForms/ListCustomFormsAction.php new file mode 100644 index 0000000..517fc7b --- /dev/null +++ b/app/Containers/Dashboard/Actions/CustomForms/ListCustomFormsAction.php @@ -0,0 +1,34 @@ +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' => 'Скрыто'], + ], + ]; + } +} diff --git a/app/Containers/Dashboard/Actions/CustomForms/ListFormResponsesAction.php b/app/Containers/Dashboard/Actions/CustomForms/ListFormResponsesAction.php new file mode 100644 index 0000000..7388d18 --- /dev/null +++ b/app/Containers/Dashboard/Actions/CustomForms/ListFormResponsesAction.php @@ -0,0 +1,53 @@ +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(); + } +} diff --git a/app/Containers/Dashboard/Actions/CustomForms/UpdateCustomFormAction.php b/app/Containers/Dashboard/Actions/CustomForms/UpdateCustomFormAction.php new file mode 100644 index 0000000..f6b02e7 --- /dev/null +++ b/app/Containers/Dashboard/Actions/CustomForms/UpdateCustomFormAction.php @@ -0,0 +1,15 @@ +update($data); + + return $form; + } +} diff --git a/app/Containers/Dashboard/Actions/Departments/AttachDepartmentProgramAction.php b/app/Containers/Dashboard/Actions/Departments/AttachDepartmentProgramAction.php new file mode 100644 index 0000000..d43b699 --- /dev/null +++ b/app/Containers/Dashboard/Actions/Departments/AttachDepartmentProgramAction.php @@ -0,0 +1,21 @@ +programs()->attach($program->id); + + $this->cacheService->clearAllCacheByModel(); + } +} diff --git a/app/Containers/Dashboard/Actions/Departments/AttachDepartmentTeacherAction.php b/app/Containers/Dashboard/Actions/Departments/AttachDepartmentTeacherAction.php new file mode 100644 index 0000000..bd64aaf --- /dev/null +++ b/app/Containers/Dashboard/Actions/Departments/AttachDepartmentTeacherAction.php @@ -0,0 +1,27 @@ +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(); + } +} diff --git a/app/Containers/Dashboard/Actions/Departments/AttachDepartmentWorkerAction.php b/app/Containers/Dashboard/Actions/Departments/AttachDepartmentWorkerAction.php new file mode 100644 index 0000000..9db65b9 --- /dev/null +++ b/app/Containers/Dashboard/Actions/Departments/AttachDepartmentWorkerAction.php @@ -0,0 +1,27 @@ +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(); + } +} diff --git a/app/Containers/Dashboard/Actions/Departments/CreateDepartmentAction.php b/app/Containers/Dashboard/Actions/Departments/CreateDepartmentAction.php new file mode 100644 index 0000000..632a5c2 --- /dev/null +++ b/app/Containers/Dashboard/Actions/Departments/CreateDepartmentAction.php @@ -0,0 +1,55 @@ +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), + ]); + } +} diff --git a/app/Containers/Dashboard/Actions/Departments/DeleteDepartmentAction.php b/app/Containers/Dashboard/Actions/Departments/DeleteDepartmentAction.php new file mode 100644 index 0000000..a96e0f1 --- /dev/null +++ b/app/Containers/Dashboard/Actions/Departments/DeleteDepartmentAction.php @@ -0,0 +1,13 @@ +delete(); + } +} diff --git a/app/Containers/Dashboard/Actions/Departments/DetachDepartmentProgramAction.php b/app/Containers/Dashboard/Actions/Departments/DetachDepartmentProgramAction.php new file mode 100644 index 0000000..4e1873b --- /dev/null +++ b/app/Containers/Dashboard/Actions/Departments/DetachDepartmentProgramAction.php @@ -0,0 +1,21 @@ +programs()->detach($program->id); + + $this->cacheService->clearAllCacheByModel(); + } +} diff --git a/app/Containers/Dashboard/Actions/Departments/DetachDepartmentTeacherAction.php b/app/Containers/Dashboard/Actions/Departments/DetachDepartmentTeacherAction.php new file mode 100644 index 0000000..6354b23 --- /dev/null +++ b/app/Containers/Dashboard/Actions/Departments/DetachDepartmentTeacherAction.php @@ -0,0 +1,21 @@ +teachers()->detach($user->id); + + $this->cacheService->clearAllCacheByModel(); + } +} diff --git a/app/Containers/Dashboard/Actions/Departments/DetachDepartmentWorkerAction.php b/app/Containers/Dashboard/Actions/Departments/DetachDepartmentWorkerAction.php new file mode 100644 index 0000000..f9eedda --- /dev/null +++ b/app/Containers/Dashboard/Actions/Departments/DetachDepartmentWorkerAction.php @@ -0,0 +1,21 @@ +workers()->detach($user->id); + + $this->cacheService->clearAllCacheByModel(); + } +} diff --git a/app/Containers/Dashboard/Actions/Departments/ListDepartmentProgramsAction.php b/app/Containers/Dashboard/Actions/Departments/ListDepartmentProgramsAction.php new file mode 100644 index 0000000..4e19b9c --- /dev/null +++ b/app/Containers/Dashboard/Actions/Departments/ListDepartmentProgramsAction.php @@ -0,0 +1,31 @@ +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, + ]; + } +} diff --git a/app/Containers/Dashboard/Actions/Departments/ListDepartmentTeachersAction.php b/app/Containers/Dashboard/Actions/Departments/ListDepartmentTeachersAction.php new file mode 100644 index 0000000..2a09716 --- /dev/null +++ b/app/Containers/Dashboard/Actions/Departments/ListDepartmentTeachersAction.php @@ -0,0 +1,30 @@ +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, + ]; + } +} diff --git a/app/Containers/Dashboard/Actions/Departments/ListDepartmentWorkersAction.php b/app/Containers/Dashboard/Actions/Departments/ListDepartmentWorkersAction.php new file mode 100644 index 0000000..39635e0 --- /dev/null +++ b/app/Containers/Dashboard/Actions/Departments/ListDepartmentWorkersAction.php @@ -0,0 +1,31 @@ +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, + ]; + } +} diff --git a/app/Containers/Dashboard/Actions/Departments/ListDepartmentsAction.php b/app/Containers/Dashboard/Actions/Departments/ListDepartmentsAction.php new file mode 100644 index 0000000..c45dc75 --- /dev/null +++ b/app/Containers/Dashboard/Actions/Departments/ListDepartmentsAction.php @@ -0,0 +1,35 @@ +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, + ]; + } +} diff --git a/app/Containers/Dashboard/Actions/Departments/UpdateDepartmentAction.php b/app/Containers/Dashboard/Actions/Departments/UpdateDepartmentAction.php new file mode 100644 index 0000000..b979e95 --- /dev/null +++ b/app/Containers/Dashboard/Actions/Departments/UpdateDepartmentAction.php @@ -0,0 +1,56 @@ +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); + } + } +} diff --git a/app/Containers/Dashboard/Actions/Departments/UpdateDepartmentTeacherAction.php b/app/Containers/Dashboard/Actions/Departments/UpdateDepartmentTeacherAction.php new file mode 100644 index 0000000..5d93908 --- /dev/null +++ b/app/Containers/Dashboard/Actions/Departments/UpdateDepartmentTeacherAction.php @@ -0,0 +1,26 @@ +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(); + } +} diff --git a/app/Containers/Dashboard/Actions/Departments/UpdateDepartmentWorkerAction.php b/app/Containers/Dashboard/Actions/Departments/UpdateDepartmentWorkerAction.php new file mode 100644 index 0000000..f4590ce --- /dev/null +++ b/app/Containers/Dashboard/Actions/Departments/UpdateDepartmentWorkerAction.php @@ -0,0 +1,26 @@ +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(); + } +} diff --git a/app/Containers/Dashboard/Actions/DirectionStudies/CreateDirectionStudyAction.php b/app/Containers/Dashboard/Actions/DirectionStudies/CreateDirectionStudyAction.php new file mode 100644 index 0000000..92da55c --- /dev/null +++ b/app/Containers/Dashboard/Actions/DirectionStudies/CreateDirectionStudyAction.php @@ -0,0 +1,13 @@ +delete(); + } +} diff --git a/app/Containers/Dashboard/Actions/DirectionStudies/ListDirectionStudiesAction.php b/app/Containers/Dashboard/Actions/DirectionStudies/ListDirectionStudiesAction.php new file mode 100644 index 0000000..87ec3fd --- /dev/null +++ b/app/Containers/Dashboard/Actions/DirectionStudies/ListDirectionStudiesAction.php @@ -0,0 +1,40 @@ +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()), + ]; + } +} diff --git a/app/Containers/Dashboard/Actions/DirectionStudies/UpdateDirectionStudyAction.php b/app/Containers/Dashboard/Actions/DirectionStudies/UpdateDirectionStudyAction.php new file mode 100644 index 0000000..67acb10 --- /dev/null +++ b/app/Containers/Dashboard/Actions/DirectionStudies/UpdateDirectionStudyAction.php @@ -0,0 +1,14 @@ +update($data); + return $direction; + } +} diff --git a/app/Containers/Dashboard/Actions/Divisions/AttachDivisionWorkerAction.php b/app/Containers/Dashboard/Actions/Divisions/AttachDivisionWorkerAction.php new file mode 100644 index 0000000..11cc4a1 --- /dev/null +++ b/app/Containers/Dashboard/Actions/Divisions/AttachDivisionWorkerAction.php @@ -0,0 +1,27 @@ +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(); + } +} diff --git a/app/Containers/Dashboard/Actions/Divisions/CreateDivisionAction.php b/app/Containers/Dashboard/Actions/Divisions/CreateDivisionAction.php new file mode 100644 index 0000000..06d2b41 --- /dev/null +++ b/app/Containers/Dashboard/Actions/Divisions/CreateDivisionAction.php @@ -0,0 +1,55 @@ +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), + ]); + } +} diff --git a/app/Containers/Dashboard/Actions/Divisions/DeleteDivisionAction.php b/app/Containers/Dashboard/Actions/Divisions/DeleteDivisionAction.php new file mode 100644 index 0000000..f97a028 --- /dev/null +++ b/app/Containers/Dashboard/Actions/Divisions/DeleteDivisionAction.php @@ -0,0 +1,13 @@ +delete(); + } +} diff --git a/app/Containers/Dashboard/Actions/Divisions/DetachDivisionWorkerAction.php b/app/Containers/Dashboard/Actions/Divisions/DetachDivisionWorkerAction.php new file mode 100644 index 0000000..c54159d --- /dev/null +++ b/app/Containers/Dashboard/Actions/Divisions/DetachDivisionWorkerAction.php @@ -0,0 +1,20 @@ +workers()->detach($worker->id); + $this->cacheService->clearAllCacheByModel(); + } +} diff --git a/app/Containers/Dashboard/Actions/Divisions/ListDivisionWorkersAction.php b/app/Containers/Dashboard/Actions/Divisions/ListDivisionWorkersAction.php new file mode 100644 index 0000000..553b578 --- /dev/null +++ b/app/Containers/Dashboard/Actions/Divisions/ListDivisionWorkersAction.php @@ -0,0 +1,33 @@ +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, + ]; + } +} diff --git a/app/Containers/Dashboard/Actions/Divisions/ListDivisionsAction.php b/app/Containers/Dashboard/Actions/Divisions/ListDivisionsAction.php new file mode 100644 index 0000000..f0f366c --- /dev/null +++ b/app/Containers/Dashboard/Actions/Divisions/ListDivisionsAction.php @@ -0,0 +1,33 @@ +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, + ]; + } +} diff --git a/app/Containers/Dashboard/Actions/Divisions/UpdateDivisionAction.php b/app/Containers/Dashboard/Actions/Divisions/UpdateDivisionAction.php new file mode 100644 index 0000000..b9ddf64 --- /dev/null +++ b/app/Containers/Dashboard/Actions/Divisions/UpdateDivisionAction.php @@ -0,0 +1,56 @@ +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); + } + } +} diff --git a/app/Containers/Dashboard/Actions/Divisions/UpdateDivisionWorkerAction.php b/app/Containers/Dashboard/Actions/Divisions/UpdateDivisionWorkerAction.php new file mode 100644 index 0000000..3e36da5 --- /dev/null +++ b/app/Containers/Dashboard/Actions/Divisions/UpdateDivisionWorkerAction.php @@ -0,0 +1,26 @@ +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(); + } +} diff --git a/app/Containers/Dashboard/Actions/EducationalGroups/CreateEducationalGroupAction.php b/app/Containers/Dashboard/Actions/EducationalGroups/CreateEducationalGroupAction.php new file mode 100644 index 0000000..8e9445b --- /dev/null +++ b/app/Containers/Dashboard/Actions/EducationalGroups/CreateEducationalGroupAction.php @@ -0,0 +1,13 @@ +delete(); + } +} diff --git a/app/Containers/Dashboard/Actions/EducationalGroups/ListEducationalGroupsAction.php b/app/Containers/Dashboard/Actions/EducationalGroups/ListEducationalGroupsAction.php new file mode 100644 index 0000000..703629c --- /dev/null +++ b/app/Containers/Dashboard/Actions/EducationalGroups/ListEducationalGroupsAction.php @@ -0,0 +1,44 @@ +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()), + ]; + } +} diff --git a/app/Containers/Dashboard/Actions/EducationalGroups/UpdateEducationalGroupAction.php b/app/Containers/Dashboard/Actions/EducationalGroups/UpdateEducationalGroupAction.php new file mode 100644 index 0000000..0c4fec5 --- /dev/null +++ b/app/Containers/Dashboard/Actions/EducationalGroups/UpdateEducationalGroupAction.php @@ -0,0 +1,14 @@ +update($data); + return $group->fresh(); + } +} diff --git a/app/Containers/Dashboard/Actions/EducationalPrograms/CreateEducationalProgramAction.php b/app/Containers/Dashboard/Actions/EducationalPrograms/CreateEducationalProgramAction.php new file mode 100644 index 0000000..8dfa3f8 --- /dev/null +++ b/app/Containers/Dashboard/Actions/EducationalPrograms/CreateEducationalProgramAction.php @@ -0,0 +1,13 @@ +delete(); + } +} diff --git a/app/Containers/Dashboard/Actions/EducationalPrograms/ListEducationalProgramsAction.php b/app/Containers/Dashboard/Actions/EducationalPrograms/ListEducationalProgramsAction.php new file mode 100644 index 0000000..3f2123d --- /dev/null +++ b/app/Containers/Dashboard/Actions/EducationalPrograms/ListEducationalProgramsAction.php @@ -0,0 +1,55 @@ +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']), + ]; + } +} diff --git a/app/Containers/Dashboard/Actions/EducationalPrograms/UpdateEducationalProgramAction.php b/app/Containers/Dashboard/Actions/EducationalPrograms/UpdateEducationalProgramAction.php new file mode 100644 index 0000000..0c0fe9a --- /dev/null +++ b/app/Containers/Dashboard/Actions/EducationalPrograms/UpdateEducationalProgramAction.php @@ -0,0 +1,14 @@ +update($data); + return $program; + } +} diff --git a/app/Containers/Dashboard/Actions/EmailNews/FetchEmailNewsAction.php b/app/Containers/Dashboard/Actions/EmailNews/FetchEmailNewsAction.php index fa1506a..f6c1946 100644 --- a/app/Containers/Dashboard/Actions/EmailNews/FetchEmailNewsAction.php +++ b/app/Containers/Dashboard/Actions/EmailNews/FetchEmailNewsAction.php @@ -189,6 +189,9 @@ class FetchEmailNewsAction 'subject' => $email['subject'] ?? 'unknown', ]); + // Помечаем письмо как прочитанное чтобы не обрабатывать повторно + $this->markEmail($email['message'], $folder); + return [ 'success' => false, 'error' => 'Нет DOC/DOCX файла для извлечения текста', diff --git a/app/Containers/Dashboard/Actions/Faculties/AttachFacultyWorkerAction.php b/app/Containers/Dashboard/Actions/Faculties/AttachFacultyWorkerAction.php new file mode 100644 index 0000000..d3fce48 --- /dev/null +++ b/app/Containers/Dashboard/Actions/Faculties/AttachFacultyWorkerAction.php @@ -0,0 +1,27 @@ +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(); + } +} diff --git a/app/Containers/Dashboard/Actions/Faculties/CreateFacultyAction.php b/app/Containers/Dashboard/Actions/Faculties/CreateFacultyAction.php new file mode 100644 index 0000000..b4718f0 --- /dev/null +++ b/app/Containers/Dashboard/Actions/Faculties/CreateFacultyAction.php @@ -0,0 +1,55 @@ +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), + ]); + } +} diff --git a/app/Containers/Dashboard/Actions/Faculties/DeleteFacultyAction.php b/app/Containers/Dashboard/Actions/Faculties/DeleteFacultyAction.php new file mode 100644 index 0000000..8fcfdfd --- /dev/null +++ b/app/Containers/Dashboard/Actions/Faculties/DeleteFacultyAction.php @@ -0,0 +1,13 @@ +delete(); + } +} diff --git a/app/Containers/Dashboard/Actions/Faculties/DetachFacultyWorkerAction.php b/app/Containers/Dashboard/Actions/Faculties/DetachFacultyWorkerAction.php new file mode 100644 index 0000000..4fda750 --- /dev/null +++ b/app/Containers/Dashboard/Actions/Faculties/DetachFacultyWorkerAction.php @@ -0,0 +1,20 @@ +workers()->detach($worker->id); + $this->cacheService->clearAllCacheByModel(); + } +} diff --git a/app/Containers/Dashboard/Actions/Faculties/ListFacultiesAction.php b/app/Containers/Dashboard/Actions/Faculties/ListFacultiesAction.php new file mode 100644 index 0000000..77584b2 --- /dev/null +++ b/app/Containers/Dashboard/Actions/Faculties/ListFacultiesAction.php @@ -0,0 +1,30 @@ +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, + ]; + } +} diff --git a/app/Containers/Dashboard/Actions/Faculties/ListFacultyWorkersAction.php b/app/Containers/Dashboard/Actions/Faculties/ListFacultyWorkersAction.php new file mode 100644 index 0000000..6023e23 --- /dev/null +++ b/app/Containers/Dashboard/Actions/Faculties/ListFacultyWorkersAction.php @@ -0,0 +1,33 @@ +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, + ]; + } +} diff --git a/app/Containers/Dashboard/Actions/Faculties/UpdateFacultyAction.php b/app/Containers/Dashboard/Actions/Faculties/UpdateFacultyAction.php new file mode 100644 index 0000000..212342d --- /dev/null +++ b/app/Containers/Dashboard/Actions/Faculties/UpdateFacultyAction.php @@ -0,0 +1,56 @@ +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); + } + } +} diff --git a/app/Containers/Dashboard/Actions/Faculties/UpdateFacultyWorkerAction.php b/app/Containers/Dashboard/Actions/Faculties/UpdateFacultyWorkerAction.php new file mode 100644 index 0000000..ccb7857 --- /dev/null +++ b/app/Containers/Dashboard/Actions/Faculties/UpdateFacultyWorkerAction.php @@ -0,0 +1,26 @@ +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(); + } +} diff --git a/app/Containers/Dashboard/Actions/FetchEmailNewsAction.php b/app/Containers/Dashboard/Actions/FetchEmailNewsAction.php deleted file mode 100644 index 32e56f0..0000000 --- a/app/Containers/Dashboard/Actions/FetchEmailNewsAction.php +++ /dev/null @@ -1,234 +0,0 @@ - 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 $attachments - * @return \Illuminate\Support\Collection - */ - 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); - } - } -} diff --git a/app/Containers/Dashboard/Actions/JournalIssues/CreateJournalIssueAction.php b/app/Containers/Dashboard/Actions/JournalIssues/CreateJournalIssueAction.php new file mode 100644 index 0000000..e469f5b --- /dev/null +++ b/app/Containers/Dashboard/Actions/JournalIssues/CreateJournalIssueAction.php @@ -0,0 +1,13 @@ +delete(); + } +} diff --git a/app/Containers/Dashboard/Actions/JournalIssues/ListJournalIssuesAction.php b/app/Containers/Dashboard/Actions/JournalIssues/ListJournalIssuesAction.php new file mode 100644 index 0000000..4aedfd0 --- /dev/null +++ b/app/Containers/Dashboard/Actions/JournalIssues/ListJournalIssuesAction.php @@ -0,0 +1,42 @@ +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, + ]; + } +} diff --git a/app/Containers/Dashboard/Actions/JournalIssues/UpdateJournalIssueAction.php b/app/Containers/Dashboard/Actions/JournalIssues/UpdateJournalIssueAction.php new file mode 100644 index 0000000..8fa675c --- /dev/null +++ b/app/Containers/Dashboard/Actions/JournalIssues/UpdateJournalIssueAction.php @@ -0,0 +1,14 @@ +update($data); + return $issue->fresh(); + } +} diff --git a/app/Containers/Dashboard/Actions/LoadDashboardDataAction.php b/app/Containers/Dashboard/Actions/LoadDashboardDataAction.php new file mode 100644 index 0000000..6425c70 --- /dev/null +++ b/app/Containers/Dashboard/Actions/LoadDashboardDataAction.php @@ -0,0 +1,25 @@ + $this->getAiPreparedPostsTask->run(), + 'stats' => $this->getDashboardStatsTask->run(), + 'recentActivity' => $this->getRecentActivityTask->run(), + ]; + } +} diff --git a/app/Containers/Dashboard/Actions/MainSections/CreateMainSectionAction.php b/app/Containers/Dashboard/Actions/MainSections/CreateMainSectionAction.php new file mode 100644 index 0000000..5d6485a --- /dev/null +++ b/app/Containers/Dashboard/Actions/MainSections/CreateMainSectionAction.php @@ -0,0 +1,16 @@ + $data['title'], + 'slug' => $data['slug'], + ]); + } +} diff --git a/app/Containers/Dashboard/Actions/MainSections/DeleteMainSectionAction.php b/app/Containers/Dashboard/Actions/MainSections/DeleteMainSectionAction.php new file mode 100644 index 0000000..b31c574 --- /dev/null +++ b/app/Containers/Dashboard/Actions/MainSections/DeleteMainSectionAction.php @@ -0,0 +1,13 @@ +delete(); + } +} diff --git a/app/Containers/Dashboard/Actions/MainSections/ListMainSectionsAction.php b/app/Containers/Dashboard/Actions/MainSections/ListMainSectionsAction.php new file mode 100644 index 0000000..56913fb --- /dev/null +++ b/app/Containers/Dashboard/Actions/MainSections/ListMainSectionsAction.php @@ -0,0 +1,23 @@ +with('subSections'); + + if (!empty($filters['search'])) { + $query->where('title', 'like', '%' . $filters['search'] . '%'); + } + + $mainSections = $query->orderBy('sort')->paginate(15); + + return [ + 'mainSections' => $mainSections, + ]; + } +} diff --git a/app/Containers/Dashboard/Actions/MainSections/UpdateMainSectionAction.php b/app/Containers/Dashboard/Actions/MainSections/UpdateMainSectionAction.php new file mode 100644 index 0000000..0c77d40 --- /dev/null +++ b/app/Containers/Dashboard/Actions/MainSections/UpdateMainSectionAction.php @@ -0,0 +1,18 @@ +update([ + 'title' => $data['title'], + 'slug' => $data['slug'], + ]); + + return $mainSection->fresh(); + } +} diff --git a/app/Containers/Dashboard/Actions/PageReferenceLists/CreatePageReferenceListAction.php b/app/Containers/Dashboard/Actions/PageReferenceLists/CreatePageReferenceListAction.php new file mode 100644 index 0000000..b2ffe29 --- /dev/null +++ b/app/Containers/Dashboard/Actions/PageReferenceLists/CreatePageReferenceListAction.php @@ -0,0 +1,18 @@ +delete(); + } +} diff --git a/app/Containers/Dashboard/Actions/PageReferenceLists/ListPageReferenceListsAction.php b/app/Containers/Dashboard/Actions/PageReferenceLists/ListPageReferenceListsAction.php new file mode 100644 index 0000000..6398a6d --- /dev/null +++ b/app/Containers/Dashboard/Actions/PageReferenceLists/ListPageReferenceListsAction.php @@ -0,0 +1,28 @@ +where('title', 'like', '%' . $filters['search'] . '%'); + } + + if (isset($filters['is_active']) && $filters['is_active'] !== '') { + $query->where('is_active', (bool) $filters['is_active']); + } + + $lists = $query->orderByDesc('created_at')->paginate(20)->withQueryString(); + + return [ + 'lists' => $lists, + 'filters' => $filters, + ]; + } +} diff --git a/app/Containers/Dashboard/Actions/PageReferenceLists/UpdatePageReferenceListAction.php b/app/Containers/Dashboard/Actions/PageReferenceLists/UpdatePageReferenceListAction.php new file mode 100644 index 0000000..9dd078c --- /dev/null +++ b/app/Containers/Dashboard/Actions/PageReferenceLists/UpdatePageReferenceListAction.php @@ -0,0 +1,15 @@ +update($data); + + return $list; + } +} diff --git a/app/Containers/Dashboard/Actions/Pages/AttachPageToSubSectionAction.php b/app/Containers/Dashboard/Actions/Pages/AttachPageToSubSectionAction.php new file mode 100644 index 0000000..ff70c7d --- /dev/null +++ b/app/Containers/Dashboard/Actions/Pages/AttachPageToSubSectionAction.php @@ -0,0 +1,24 @@ +findOrFail($page->id); + + if ($page->sub_section_id !== null) { + throw new \InvalidArgumentException('Страница уже принадлежит другому подразделу'); + } + + $page->section()->associate($subSection); + $page->save(); + + return $page; + } +} diff --git a/app/Containers/Dashboard/Actions/Pages/CreatePageAction.php b/app/Containers/Dashboard/Actions/Pages/CreatePageAction.php new file mode 100644 index 0000000..76cd325 --- /dev/null +++ b/app/Containers/Dashboard/Actions/Pages/CreatePageAction.php @@ -0,0 +1,38 @@ +generatePagePathAction->run($data['slug'], $subSectionId); + + // Генерируем search_data из контента + if (!empty($data['content'])) { + $data['search_data'] = $this->generateSearchDataTask->run($data['content']); + } + + // Удаляем sub_section_id — он не является полем модели Page + unset($data['sub_section_id']); + + return Page::create($data); + } +} diff --git a/app/Containers/Dashboard/Actions/Pages/DeletePageAction.php b/app/Containers/Dashboard/Actions/Pages/DeletePageAction.php new file mode 100644 index 0000000..f312a4c --- /dev/null +++ b/app/Containers/Dashboard/Actions/Pages/DeletePageAction.php @@ -0,0 +1,19 @@ +delete(); + } +} diff --git a/app/Containers/Dashboard/Actions/Pages/DetachPageFromSubSectionAction.php b/app/Containers/Dashboard/Actions/Pages/DetachPageFromSubSectionAction.php new file mode 100644 index 0000000..b7c51e9 --- /dev/null +++ b/app/Containers/Dashboard/Actions/Pages/DetachPageFromSubSectionAction.php @@ -0,0 +1,16 @@ +section()->dissociate(); + $page->save(); + + return $page; + } +} diff --git a/app/Containers/Dashboard/Actions/Pages/GeneratePagePathAction.php b/app/Containers/Dashboard/Actions/Pages/GeneratePagePathAction.php new file mode 100644 index 0000000..8165392 --- /dev/null +++ b/app/Containers/Dashboard/Actions/Pages/GeneratePagePathAction.php @@ -0,0 +1,27 @@ +find($subSectionId); + + if ($subSection === null) { + return $slug; + } + + if ($subSection->mainSection === null) { + return $subSection->slug . '/' . $slug; + } + + return $subSection->mainSection->slug . '/' . $subSection->slug . '/' . $slug; + } +} diff --git a/app/Containers/Dashboard/Actions/Pages/ListPagesAction.php b/app/Containers/Dashboard/Actions/Pages/ListPagesAction.php new file mode 100644 index 0000000..d9e600e --- /dev/null +++ b/app/Containers/Dashboard/Actions/Pages/ListPagesAction.php @@ -0,0 +1,53 @@ +with(['section.mainSection']); + + // Search + if (!empty($filters['search'])) { + $query->where(function ($q) use ($filters) { + $q->where('title', 'like', '%' . $filters['search'] . '%') + ->orWhere('path', 'like', '%' . $filters['search'] . '%'); + }); + } + + // Tab filter + if (!empty($filters['tab'])) { + if ($filters['tab'] === 'is_registered') { + $query->where('is_registered', true)->where('is_url', false); + } elseif ($filters['tab'] === 'is_url') { + $query->where('is_url', true); + } else { + $query->where('is_registered', false)->where('is_url', false); + } + } else { + // Default: show created pages + $query->where('is_registered', false)->where('is_url', false); + } + + // SubSection filter + if (!empty($filters['sub_section_id'])) { + $query->where('sub_section_id', $filters['sub_section_id']); + } + + $pages = $query->orderBy('created_at', 'desc')->paginate(15); + + $subSections = SubSection::with('mainSection') + ->whereNotNull('title') + ->orderBy('title') + ->get(); + + return [ + 'pages' => $pages, + 'subSections' => $subSections, + ]; + } +} diff --git a/app/Containers/Dashboard/Actions/Pages/UpdatePageAction.php b/app/Containers/Dashboard/Actions/Pages/UpdatePageAction.php new file mode 100644 index 0000000..d2fdae9 --- /dev/null +++ b/app/Containers/Dashboard/Actions/Pages/UpdatePageAction.php @@ -0,0 +1,44 @@ +content)) { + $data['search_data'] = $this->generateSearchDataTask->run($data['content']); + } + + // Если страница не зарегистрирована, перегенерируем path + if ($page->is_registered == false) { + $subSectionId = $data['sub_section_id'] ?? $page->sub_section_id; + $slug = $data['slug'] ?? $page->slug; + + $data['path'] = $this->generatePagePathAction->run($slug, $subSectionId); + } + + // Удаляем sub_section_id — он не является полем модели Page + unset($data['sub_section_id']); + + $page->update($data); + + return $page->fresh(); + } +} diff --git a/app/Containers/Dashboard/Actions/Posts/BulkDeletePostsAction.php b/app/Containers/Dashboard/Actions/Posts/BulkDeletePostsAction.php new file mode 100644 index 0000000..847ab97 --- /dev/null +++ b/app/Containers/Dashboard/Actions/Posts/BulkDeletePostsAction.php @@ -0,0 +1,17 @@ +bulkDeletePostsTask->run($ids); + } +} diff --git a/app/Containers/Dashboard/Actions/Posts/BulkPublishPostsAction.php b/app/Containers/Dashboard/Actions/Posts/BulkPublishPostsAction.php new file mode 100644 index 0000000..68cf137 --- /dev/null +++ b/app/Containers/Dashboard/Actions/Posts/BulkPublishPostsAction.php @@ -0,0 +1,19 @@ +bulkUpdatePostStatusTask->run($ids, PostStatus::PUBLISHED, Carbon::now()); + } +} diff --git a/app/Containers/Dashboard/Actions/Posts/BulkVerificationPostsAction.php b/app/Containers/Dashboard/Actions/Posts/BulkVerificationPostsAction.php new file mode 100644 index 0000000..6e968fc --- /dev/null +++ b/app/Containers/Dashboard/Actions/Posts/BulkVerificationPostsAction.php @@ -0,0 +1,18 @@ +bulkUpdatePostStatusTask->run($ids, PostStatus::VERIFICATION); + } +} diff --git a/app/Containers/Dashboard/Actions/Posts/CreatePostAction.php b/app/Containers/Dashboard/Actions/Posts/CreatePostAction.php new file mode 100644 index 0000000..96851f9 --- /dev/null +++ b/app/Containers/Dashboard/Actions/Posts/CreatePostAction.php @@ -0,0 +1,49 @@ +createPostTask->run($data); + + // Обрабатываем слайдер (Task) + $this->handleSliderTask->run($post, $slideData, true); + + // Отправляем уведомления (Task) + $this->sendNotificationTask->run($post, null, true); + + // Публикуем в VK (Task) + $this->publishToVkTask->run($post, $shouldPublishToVk, false); + + return $post; + } +} diff --git a/app/Containers/Dashboard/Actions/Posts/DeletePostAction.php b/app/Containers/Dashboard/Actions/Posts/DeletePostAction.php new file mode 100644 index 0000000..a5c2f06 --- /dev/null +++ b/app/Containers/Dashboard/Actions/Posts/DeletePostAction.php @@ -0,0 +1,18 @@ +deletePostTask->run($post); + } +} diff --git a/app/Containers/Dashboard/Actions/Posts/GetPostFormDataAction.php b/app/Containers/Dashboard/Actions/Posts/GetPostFormDataAction.php new file mode 100644 index 0000000..94d461a --- /dev/null +++ b/app/Containers/Dashboard/Actions/Posts/GetPostFormDataAction.php @@ -0,0 +1,17 @@ +getPostFormDataTask->run(); + } +} diff --git a/app/Containers/Dashboard/Actions/Posts/ListAiPreparedPostsAction.php b/app/Containers/Dashboard/Actions/Posts/ListAiPreparedPostsAction.php new file mode 100644 index 0000000..028a35e --- /dev/null +++ b/app/Containers/Dashboard/Actions/Posts/ListAiPreparedPostsAction.php @@ -0,0 +1,18 @@ +getAiPreparedPostsTask->run(); + } +} diff --git a/app/Containers/Dashboard/Actions/Posts/ListPostsAction.php b/app/Containers/Dashboard/Actions/Posts/ListPostsAction.php new file mode 100644 index 0000000..4231e41 --- /dev/null +++ b/app/Containers/Dashboard/Actions/Posts/ListPostsAction.php @@ -0,0 +1,18 @@ +listPostsTask->run($filters, $perPage); + } +} diff --git a/app/Containers/Dashboard/Actions/PublishPostAction.php b/app/Containers/Dashboard/Actions/Posts/PublishPostAction.php similarity index 98% rename from app/Containers/Dashboard/Actions/PublishPostAction.php rename to app/Containers/Dashboard/Actions/Posts/PublishPostAction.php index aa77521..d46a919 100644 --- a/app/Containers/Dashboard/Actions/PublishPostAction.php +++ b/app/Containers/Dashboard/Actions/Posts/PublishPostAction.php @@ -1,6 +1,6 @@ uploadFileTask->run($file); + } +} diff --git a/app/Containers/Dashboard/Actions/Posts/UpdatePostAction.php b/app/Containers/Dashboard/Actions/Posts/UpdatePostAction.php new file mode 100644 index 0000000..e67c458 --- /dev/null +++ b/app/Containers/Dashboard/Actions/Posts/UpdatePostAction.php @@ -0,0 +1,51 @@ +updatePostTask->run($post, $data); + + // Обрабатываем слайдер (Task) + $this->handleSliderTask->run($post, $slideData, false); + + // Отправляем уведомления (Task) + $this->sendNotificationTask->run($post, $newStatus, false); + + // Публикуем в VK (Task) + $this->publishToVkTask->run($post, $shouldPublishToVk, true); + + return $post; + } +} diff --git a/app/Containers/Dashboard/Actions/ProcessMixedFilesAction.php b/app/Containers/Dashboard/Actions/ProcessMixedFilesAction.php deleted file mode 100644 index b96b43c..0000000 --- a/app/Containers/Dashboard/Actions/ProcessMixedFilesAction.php +++ /dev/null @@ -1,267 +0,0 @@ - $files Все загруженные файлы - * @return array Данные о созданном посте - */ - public function run(Collection $files): array - { - Log::info('[ProcessMixedFilesAction] Начало обработки файлов', [ - 'files_count' => $files->count(), - 'files' => $files->map(fn($f) => $f->getClientOriginalName())->toArray(), - ]); - - // Находим основной файл с текстом новости - $mainFile = $this->findMainNewsFileTask->run($files); - - if (!$mainFile) { - Log::error('[ProcessMixedFilesAction] Не найден файл для извлечения текста'); - throw new \RuntimeException('Не найден DOC/DOCX файл для извлечения текста'); - } - - Log::info('[ProcessMixedFilesAction] Основной файл найден', [ - 'file' => $mainFile->getClientOriginalName(), - 'extension' => $mainFile->getClientOriginalExtension(), - 'real_path' => $mainFile->getRealPath(), - 'exists' => file_exists($mainFile->getRealPath()), - ]); - - // Извлекаем текст из основного файла (поддерживает и .doc, и .docx) - Log::info('[ProcessMixedFilesAction] Начало извлечения текста из документа'); - try { - $extractedText = $this->extractTextFromDocumentTask->run($mainFile); - Log::info('[ProcessMixedFilesAction] Текст извлечен', [ - 'text_length' => strlen($extractedText ?? ''), - 'text_preview' => substr($extractedText ?? '', 0, 100), - ]); - } catch (\Exception $e) { - Log::error('[ProcessMixedFilesAction] Ошибка при извлечении текста', [ - 'error' => $e->getMessage(), - 'file' => $mainFile->getClientOriginalName(), - ]); - throw $e; - } - - Log::info('[ProcessMixedFilesAction] Результат извлечения текста', [ - 'text_length' => strlen($extractedText ?? ''), - 'has_text' => !empty($extractedText), - ]); - - if (empty($extractedText)) { - Log::warning('[ProcessMixedFilesAction] Пустой текст после извлечения', [ - 'file' => $mainFile->getClientOriginalName(), - ]); - } - - // Сохраняем все файлы - ['documentPath' => $documentPath, 'mediaPaths' => $mediaPaths] = $this->saveFiles($files, $mainFile); - - // Обрабатываем прикреплённые файлы из media - $attachedFiles = $this->processAttachedFiles($files, $mainFile); - - // Получаем категории - $categories = Category::all(); - - // Отправляем текст в AI - Log::info('[ProcessMixedFilesAction] Отправка текста в AI сервис', [ - 'text_length' => strlen($extractedText ?? ''), - 'categories_count' => $categories->count(), - ]); - - try { - $newsData = $this->callAiServiceTask->run($extractedText, $categories); - } catch (\Exception $e) { - Log::error('[ProcessMixedFilesAction] Ошибка вызова AI сервиса', [ - 'error' => $e->getMessage(), - 'trace' => $e->getTraceAsString(), - ]); - throw new \RuntimeException('Ошибка при обработке данных AI: ' . $e->getMessage(), 0, $e); - } - - if (!$newsData) { - Log::error('[ProcessMixedFilesAction] AI сервис вернул пустой ответ'); - throw new \RuntimeException('Не удалось распознать данные через AI сервис. Проверьте логи AI запроса.'); - } - - Log::info('[ProcessMixedFilesAction] AI данные успешно получены', [ - 'title' => $newsData['title'] ?? 'N/A', - 'category_id' => $newsData['category_id'] ?? 'N/A', - ]); - - // Создаём пост - $post = $this->createPostFromAiDataTask->run($newsData, $documentPath, $mediaPaths, $attachedFiles); - - // Возвращаем данные для отображения - return $this->prepareResponse($post, $newsData); - } - - /** - * Сохраняет все файлы - */ - private function saveFiles(Collection $files, UploadedFile $mainFile): array - { - Log::info('[ProcessMixedFilesAction:saveFiles] Начало сохранения файлов', [ - 'total_files' => $files->count(), - ]); - - // Сохраняем основной документ - $documentPath = $mainFile->store('documents', 'local'); - - // Сжимаем и сохраняем остальные файлы как медиа - $mediaPaths = []; - $compressionStats = ['total' => 0, 'compressed' => 0, 'saved_bytes' => 0]; - - foreach ($files as $file) { - // Пропускаем основной файл - if ($file === $mainFile) { - continue; - } - - $compressionStats['total']++; - - // Если это изображение - сжимаем - if ($this->compressImageTask->isImage($file)) { - Log::info('[ProcessMixedFilesAction:saveFiles] Обработка изображения', [ - 'file' => $file->getClientOriginalName(), - ]); - - $result = $this->compressImageTask->run($file); - - if ($result['compressed']) { - $compressionStats['compressed']++; - $compressionStats['saved_bytes'] += $result['original_size'] - $result['size']; - - Log::info('[ProcessMixedFilesAction:saveFiles] Изображение сжато', [ - 'file' => $file->getClientOriginalName(), - 'original_size' => $this->formatFileSize($result['original_size']), - 'compressed_size' => $this->formatFileSize($result['size']), - 'ratio' => $result['compression_ratio'] . '%', - ]); - } - - // Сохраняем сжатый файл - $path = $result['file']->store('media', 'public'); - - // Очищаем временный файл - if (file_exists($result['file']->getRealPath())) { - unlink($result['file']->getRealPath()); - } - - $mediaPaths[] = $path; - } else { - // Не изображения сохраняем как есть - $path = $file->store('media', 'public'); - $mediaPaths[] = $path; - } - } - - Log::info('[ProcessMixedFilesAction:saveFiles] Статистика сжатия', [ - 'total_images' => $compressionStats['total'], - 'compressed' => $compressionStats['compressed'], - 'saved' => $this->formatFileSize($compressionStats['saved_bytes']), - 'saved_bytes' => $compressionStats['saved_bytes'], - ]); - - return [ - 'documentPath' => $documentPath, - 'mediaPaths' => $mediaPaths, - 'compressionStats' => $compressionStats, - ]; - } - - /** - * Обрабатывает прикреплённые файлы (для добавления в контент) - */ - private function processAttachedFiles(Collection $files, UploadedFile $mainFile): array - { - $attachedFiles = []; - $fileExtensions = ['doc', 'docx', 'pdf', 'xls', 'xlsx', 'ppt', 'pptx']; - - foreach ($files as $file) { - // Пропускаем основной файл - if ($file === $mainFile) { - continue; - } - - $extension = strtolower($file->getClientOriginalExtension()); - - // Пропускаем изображения - if (in_array($extension, ['jpg', 'jpeg', 'png', 'webp', 'gif', 'bmp'])) { - continue; - } - - // Обрабатываем только файлы-вложения - if (!in_array($extension, $fileExtensions)) { - continue; - } - - $savedPath = $file->store('media/attachments', 'public'); - - $attachedFiles[] = [ - 'expansion' => $extension, - 'size' => $this->formatFileSize($file->getSize()), - 'time_added' => time(), - 'title' => $file->getClientOriginalName(), - 'path' => $savedPath, - ]; - } - - return $attachedFiles; - } - - /** - * Подготавливает ответ для возврата - */ - private function prepareResponse(Post $post, array $newsData): array - { - $post->load('category'); - - return [ - 'post' => $post, - 'newsData' => $newsData, - 'preview_url' => $post->preview ? asset('storage/' . $post->preview) : null, - 'images_urls' => $post->images - ? array_map(fn($img) => asset('storage/' . $img), $post->images) - : [], - ]; - } - - /** - * Форматирует размер файла - */ - private function formatFileSize(int $bytes): string - { - $units = ['б', 'КиБ', 'МиБ', 'ГиБ']; - $bytes = max($bytes, 0); - $pow = floor(($bytes ? log($bytes) : 0) / log(1024)); - $pow = min($pow, count($units) - 1); - $bytes /= (1 << (10 * $pow)); - - return round($bytes, 2) . ' ' . $units[$pow]; - } -} diff --git a/app/Containers/Dashboard/Actions/ProcessUploadedFilesAction.php b/app/Containers/Dashboard/Actions/ProcessUploadedFilesAction.php deleted file mode 100644 index c5e8640..0000000 --- a/app/Containers/Dashboard/Actions/ProcessUploadedFilesAction.php +++ /dev/null @@ -1,199 +0,0 @@ -extractText($document); - - // Сохраняем документ - $documentPath = $document->store('documents', 'local'); - - // Сохраняем медиафайлы - $mediaPaths = $this->saveMediaFiles($mediaFiles); - - // Обрабатываем прикреплённые файлы из media (DOCX, PDF и т.д.) - $attachedFiles = $this->processAttachedFiles($mediaFiles); - - // Получаем категории - $categories = Category::all(); - - // Отправляем текст в AI - $newsData = $this->callAiService($extractedText, $categories); - - if (!$newsData) { - throw new \RuntimeException('Не удалось распознать данные через AI сервис'); - } - - // Создаём пост - $post = $this->createPost($newsData, $documentPath, $mediaPaths, $attachedFiles); - - // Возвращаем данные для отображения - return $this->prepareResponse($post, $newsData); - } - - /** - * Извлекает текст из документа - */ - private function extractText(UploadedFile $document): ?string - { - return $this->extractTextFromDocumentTask->run($document); - } - - /** - * Сохраняет медиафайлы со сжатием - */ - private function saveMediaFiles(array $mediaFiles): array - { - $paths = []; - $compressionStats = ['total' => 0, 'compressed' => 0, 'saved_bytes' => 0]; - - foreach ($mediaFiles as $file) { - $compressionStats['total']++; - - // Если это изображение - сжимаем - if ($this->compressImageTask->isImage($file)) { - $result = $this->compressImageTask->run($file); - - if ($result['compressed']) { - $compressionStats['compressed']++; - $compressionStats['saved_bytes'] += $result['original_size'] - $result['size']; - } - - // Сохраняем сжатый файл - $path = $result['file']->store('media', 'public'); - - // Очищаем временный файл - if (file_exists($result['file']->getRealPath())) { - unlink($result['file']->getRealPath()); - } - - $paths[] = $path; - } else { - // Не изображения сохраняем как есть - $path = $file->store('media', 'public'); - $paths[] = $path; - } - } - - Log::info('[ProcessUploadedFilesAction] Статистика сжатия', [ - 'total_images' => $compressionStats['total'], - 'compressed' => $compressionStats['compressed'], - 'saved' => $this->formatFileSize($compressionStats['saved_bytes']), - ]); - - return $paths; - } - - /** - * Обрабатывает прикреплённые файлы (для добавления в контент) - */ - private function processAttachedFiles(array $mediaFiles): array - { - $attachedFiles = []; - $fileExtensions = ['doc', 'docx', 'pdf', 'xls', 'xlsx', 'ppt', 'pptx', 'zip', 'rar']; - - foreach ($mediaFiles as $file) { - $extension = strtolower($file->getClientOriginalExtension()); - - // Пропускаем изображения - if (in_array($extension, ['jpg', 'jpeg', 'png', 'webp', 'gif', 'bmp'])) { - continue; - } - - // Обрабатываем только файлы-вложения - if (!in_array($extension, $fileExtensions)) { - continue; - } - - $savedPath = $file->store('media/attachments', 'public'); - - $attachedFiles[] = [ - 'expansion' => $extension, - 'size' => $this->formatFileSize($file->getSize()), - 'time_added' => time(), - 'title' => $file->getClientOriginalName(), - 'path' => $savedPath, - ]; - } - - return $attachedFiles; - } - - /** - * Вызывает AI сервис для распознавания данных - */ - private function callAiService(?string $text, $categories): ?array - { - if (!$text) { - return null; - } - - return $this->callAiServiceTask->run($text, $categories); - } - - /** - * Создаёт пост из данных - */ - private function createPost(array $newsData, ?string $documentPath, array $mediaPaths, array $attachedFiles): Post - { - return $this->createPostFromAiDataTask->run($newsData, $documentPath, $mediaPaths, $attachedFiles); - } - - /** - * Подготавливает ответ для возврата - */ - private function prepareResponse(Post $post, array $newsData): array - { - $post->load('category'); - - return [ - 'post' => $post, - 'newsData' => $newsData, - 'preview_url' => $post->preview ? asset('storage/' . $post->preview) : null, - 'images_urls' => $post->images - ? array_map(fn($img) => asset('storage/' . $img), $post->images) - : [], - ]; - } - - /** - * Форматирует размер файла - */ - private function formatFileSize(int $bytes): string - { - $units = ['б', 'КиБ', 'МиБ', 'ГиБ']; - $bytes = max($bytes, 0); - $pow = floor(($bytes ? log($bytes) : 0) / log(1024)); - $pow = min($pow, count($units) - 1); - $bytes /= (1 << (10 * $pow)); - - return round($bytes, 2) . ' ' . $units[$pow]; - } -} diff --git a/app/Containers/Dashboard/Actions/Schedules/CreateScheduleAction.php b/app/Containers/Dashboard/Actions/Schedules/CreateScheduleAction.php new file mode 100644 index 0000000..d37b441 --- /dev/null +++ b/app/Containers/Dashboard/Actions/Schedules/CreateScheduleAction.php @@ -0,0 +1,13 @@ +delete(); + } +} diff --git a/app/Containers/Dashboard/Actions/Schedules/ListSchedulesAction.php b/app/Containers/Dashboard/Actions/Schedules/ListSchedulesAction.php new file mode 100644 index 0000000..1c1c7df --- /dev/null +++ b/app/Containers/Dashboard/Actions/Schedules/ListSchedulesAction.php @@ -0,0 +1,48 @@ +where('educational_group_id', $filters['educational_group_id']); + } + + // Фильтр по форме обучения (через таблицу educational_groups) + if (!empty($filters['education_form_id'])) { + $query->whereHas('educationalGroup', function ($q) use ($filters) { + $q->where('education_form_id', $filters['education_form_id']); + }); + } + + // Поиск по названию группы + if (!empty($filters['search'])) { + $query->whereHas('educationalGroup', function ($q) use ($filters) { + $q->where('title', 'like', '%' . $filters['search'] . '%'); + }); + } + + $schedules = $query->orderByDesc('updated_at')->paginate(20)->withQueryString(); + + return [ + 'schedules' => $schedules, + 'filters' => $filters, + 'educationalGroups' => EducationalGroup::orderBy('title')->get(['id', 'title']), + 'educationForms' => array_map(fn($form) => [ + 'value' => $form->value, + 'label' => $form->getLabel(), + 'color' => $form->getColor(), + 'name' => $form->name, + ], FormEducation::cases()), + ]; + } +} diff --git a/app/Containers/Dashboard/Actions/Schedules/UpdateScheduleAction.php b/app/Containers/Dashboard/Actions/Schedules/UpdateScheduleAction.php new file mode 100644 index 0000000..31e08c9 --- /dev/null +++ b/app/Containers/Dashboard/Actions/Schedules/UpdateScheduleAction.php @@ -0,0 +1,14 @@ +update($data); + return $schedule->fresh(); + } +} diff --git a/app/Containers/Dashboard/Actions/Schedules/UploadMultipleSchedulesAction.php b/app/Containers/Dashboard/Actions/Schedules/UploadMultipleSchedulesAction.php new file mode 100644 index 0000000..b2e5377 --- /dev/null +++ b/app/Containers/Dashboard/Actions/Schedules/UploadMultipleSchedulesAction.php @@ -0,0 +1,197 @@ +processedFiles = []; + $this->failedFiles = []; + + foreach ($files as $file) { + $this->processFile($file); + } + + return [ + 'processed' => $this->processedFiles, + 'failed' => $this->failedFiles, + 'processed_count' => count($this->processedFiles), + 'failed_count' => count($this->failedFiles), + ]; + } + + /** + * Обрабатывает одиночный файл + */ + private function processFile($file): void + { + try { + $originalFileNameWithExtension = $file->getClientOriginalName(); + $originalFileName = pathinfo($originalFileNameWithExtension, PATHINFO_FILENAME); + + // Проверяем, был ли файл уже обработан + if (in_array($originalFileNameWithExtension, array_column($this->processedFiles, 'filename'))) { + return; + } + + $educationalGroup = $this->findEducationalGroupByFileName($originalFileName); + + if (!$educationalGroup) { + // Извлекаем очищенное название для более понятного сообщения об ошибке + $fileNameWithoutExt = pathinfo($originalFileName, PATHINFO_FILENAME); + $cleanedName = preg_replace('/\s+с\s+\d{2}\.\d{2}(\.\d{4})?\s+по\s+\d{2}\.\d{2}(\.\d{4})?/ui', '', $fileNameWithoutExt); + $cleanedName = preg_replace('/\s*(Экзамены|расписание|сессия|контрольная|\d{4}).*$/ui', '', $cleanedName); + $cleanedName = trim(preg_replace('/\s+/', ' ', $cleanedName)); + + $this->failedFiles[] = [ + 'filename' => $originalFileNameWithExtension, + 'error' => "Группа '{$cleanedName}' не найдена в базе. Проверьте правильность названия группы.", + ]; + return; + } + + // Проверяем наличие расписания для данной группы + $existingSchedule = Schedule::where('educational_group_id', $educationalGroup->id) + ->whereJsonContains('file->0->title', $originalFileName) + ->first(); + + if ($existingSchedule) { + $this->failedFiles[] = [ + 'filename' => $originalFileNameWithExtension, + 'error' => 'Расписание уже существует', + ]; + return; + } + + // Сохраняем файл + $uniqueFileName = Str::slug($originalFileName) . '-' . Carbon::now()->timestamp . '.' . $file->getClientOriginalExtension(); + $path = $file->storeAs('schedules', $uniqueFileName, 'public'); + + // Создаем запись + DB::transaction(function () use ($educationalGroup, $originalFileName, $path) { + Schedule::create([ + 'educational_group_id' => $educationalGroup->id, + 'file' => [ + [ + 'title' => $originalFileName, + 'path' => $path, + ], + ], + ]); + }); + + $this->processedFiles[] = [ + 'filename' => $originalFileNameWithExtension, + 'group' => $educationalGroup->title, + ]; + + } catch (\Throwable $e) { + $this->failedFiles[] = [ + 'filename' => $file->getClientOriginalName(), + 'error' => $e->getMessage(), + ]; + } + } + + /** + * Находит образовательную группу по названию файла + * + * Алгоритм: + * 1. Очищаем название файла от лишней информации (даты, слова "Экзамены", "расписание" и т.д.) + * 2. Извлекаем код группы по паттернам + * 3. Ищем точное совпадение в базе + */ + private function findEducationalGroupByFileName(string $fileName): ?EducationalGroup + { + $fileNameWithoutExt = pathinfo($fileName, PATHINFO_FILENAME); + + // Нормализуем имя файла - убираем лишние пробелы + $fileNameWithoutExt = preg_replace('/\s+/', ' ', trim($fileNameWithoutExt)); + + // ========== ШАГ 1: Очищаем название файла от лишней информации ========== + + // Удаляем даты в форматах: "с 06.04 по 09.04.2026", "с 10.01.2026 по 15.01.2026" + $cleanedName = preg_replace('/\s+с\s+\d{2}\.\d{2}(\.\d{4})?\s+по\s+\d{2}\.\d{2}(\.\d{4})?/ui', '', $fileNameWithoutExt); + + // Удаляем слова "Экзамены", "расписание", "сессия" и годы + $cleanedName = preg_replace('/\s*(Экзамены|расписание|сессия|контрольная|\d{4}).*$/ui', '', $cleanedName); + + // Финальная очистка - убираем лишние пробелы + $cleanedName = trim(preg_replace('/\s+/', ' ', $cleanedName)); + + // ========== ШАГ 2: Извлечение кода группы по паттернам ========== + + // Паттерн 1: "Нт-XXXо YYY" или "Нт-XXXоYYY" (очная форма, например: Нт-101о ИРЭ, Нт-211Со Экл) + if (preg_match('/^(Нт-\d{2,3}[а-яА-ЯёЁ]?\s*[а-яА-ЯёЁ]{2,5})/u', $cleanedName, $matches)) { + $groupCode = trim($matches[1]); + // Нормализуем: убираем лишние пробелы между номером и буквами + $groupCode = preg_replace('/(\d)(\s+)([а-яА-ЯёЁ])/u', '$1$3', $groupCode); + + // Ищем ТОЧНОЕ совпадение + $group = EducationalGroup::where('title', $groupCode)->first(); + if ($group) { + return $group; + } + } + + // Паттерн 2: "Нт-XXX YYY" (заочная форма, например: Нт-404 ИП, Нт-501 СР) + if (preg_match('/^(Нт-\d{3}\s+[А-Я]{2,4})(?![а-яА-Я])/u', $cleanedName, $matches)) { + $groupCode = trim($matches[1]); + $group = EducationalGroup::where('title', $groupCode)->first(); + if ($group) { + return $group; + } + } + + // Паттерн 3: "нXXX YY-о3" (например: н101 ГД-о3, н303 ФК-По3) + if (preg_match('/^(н\d{3}\s+[А-Яа-я\-]+-о[34])/u', $cleanedName, $matches)) { + $groupCode = trim($matches[1]); + $group = EducationalGroup::where('title', $groupCode)->first(); + if ($group) { + return $group; + } + } + + // Паттерн 4: "Нт-XXX мYYY" (магистратура, например: Нт-102 мТМОД) + if (preg_match('/^(Нт-\d{3}\s+м[А-Я]{2,5})/u', $cleanedName, $matches)) { + $groupCode = trim($matches[1]); + $group = EducationalGroup::where('title', $groupCode)->first(); + if ($group) { + return $group; + } + } + + // Паттерн 5: "БФКф-XXXX" (например: БФКф-2531) + if (preg_match('/^(БФКф-\d{4})/u', $cleanedName, $matches)) { + $groupCode = trim($matches[1]); + $group = EducationalGroup::where('title', $groupCode)->first(); + if ($group) { + return $group; + } + } + + // ========== ШАГ 3: Точное совпадение по очищенному имени файла ========== + $exactMatch = EducationalGroup::where('title', $cleanedName)->first(); + if ($exactMatch) { + return $exactMatch; + } + + return null; + } +} diff --git a/app/Containers/Dashboard/Actions/Sliders/CreateSlideAction.php b/app/Containers/Dashboard/Actions/Sliders/CreateSlideAction.php new file mode 100644 index 0000000..3dc4278 --- /dev/null +++ b/app/Containers/Dashboard/Actions/Sliders/CreateSlideAction.php @@ -0,0 +1,57 @@ +slides()->max('sort') ?? 0; + + // Формируем структуру image как в Filament: {url, shading} + $imageData = [ + 'url' => null, + 'shading' => $data['image_shading'] ?? '0.5', + ]; + + // Обрабатываем изображение если это файл + if (isset($data['image']) && $data['image'] instanceof UploadedFile) { + $uploaded = $this->uploadImageTask->run($data['image']); + $imageData['url'] = $uploaded['url']; + } + + $data['image'] = $imageData; + unset($data['image_shading']); + + // Формируем settings как в Filament: только text_position и link_text + $data['settings'] = [ + 'text_position' => $data['settings']['text_position'] ?? 'left', + 'link_text' => $data['settings']['link_text'] ?? 'Читать', + ]; + + // active_button - отдельное поле в базе (не в settings!) + // Но Filament хранит его в settings, проверим структуру + $data['settings']['active_button'] = isset($data['active_button']) ? (bool) $data['active_button'] : true; + + // Преобразуем is_active в boolean + if (isset($data['is_active'])) { + $data['is_active'] = (bool) $data['is_active']; + } + + $slide = new Slide($data); + $slide->sort = $maxSort + 1; + + $slider->slides()->save($slide); + + return $slide; + } +} diff --git a/app/Containers/Dashboard/Actions/Sliders/CreateSliderAction.php b/app/Containers/Dashboard/Actions/Sliders/CreateSliderAction.php new file mode 100644 index 0000000..45c1af9 --- /dev/null +++ b/app/Containers/Dashboard/Actions/Sliders/CreateSliderAction.php @@ -0,0 +1,16 @@ +delete(); + } +} diff --git a/app/Containers/Dashboard/Actions/Sliders/DeleteSliderAction.php b/app/Containers/Dashboard/Actions/Sliders/DeleteSliderAction.php new file mode 100644 index 0000000..de1d479 --- /dev/null +++ b/app/Containers/Dashboard/Actions/Sliders/DeleteSliderAction.php @@ -0,0 +1,13 @@ +delete(); + } +} diff --git a/app/Containers/Dashboard/Actions/Sliders/ListSlidersAction.php b/app/Containers/Dashboard/Actions/Sliders/ListSlidersAction.php new file mode 100644 index 0000000..bb855b1 --- /dev/null +++ b/app/Containers/Dashboard/Actions/Sliders/ListSlidersAction.php @@ -0,0 +1,26 @@ +withCount('slides') + ->when(isset($filters['is_active']), function ($query) use ($filters) { + $query->where('is_active', $filters['is_active']); + }) + ->when(isset($filters['search']), function ($query) use ($filters) { + $query->where(function ($q) use ($filters) { + $q->where('title', 'like', '%' . $filters['search'] . '%') + ->orWhere('slug', 'like', '%' . $filters['search'] . '%'); + }); + }); + + return $query->orderBy('title', 'asc')->paginate(20); + } +} diff --git a/app/Containers/Dashboard/Actions/Sliders/ListSlidesAction.php b/app/Containers/Dashboard/Actions/Sliders/ListSlidesAction.php new file mode 100644 index 0000000..d3a202d --- /dev/null +++ b/app/Containers/Dashboard/Actions/Sliders/ListSlidesAction.php @@ -0,0 +1,17 @@ +slides() + ->orderBy('sort') + ->get(); + } +} diff --git a/app/Containers/Dashboard/Actions/Sliders/UpdateSlideAction.php b/app/Containers/Dashboard/Actions/Sliders/UpdateSlideAction.php new file mode 100644 index 0000000..e859a49 --- /dev/null +++ b/app/Containers/Dashboard/Actions/Sliders/UpdateSlideAction.php @@ -0,0 +1,70 @@ +image ?? []; + if (!is_array($imageData)) { + $imageData = []; + } + + // Обновляем shading если передан + if (isset($data['image_shading'])) { + $imageData['shading'] = $data['image_shading']; + unset($data['image_shading']); + } + + // Обрабатываем новое изображение если это файл + if (isset($data['image']) && $data['image'] instanceof UploadedFile) { + $uploaded = $this->uploadImageTask->run($data['image']); + $imageData['url'] = $uploaded['url']; + } + + // Сохраняем image + if (!empty($imageData)) { + $data['image'] = $imageData; + } + + // Формируем settings + if (isset($data['settings'])) { + $settings = $slide->settings ?? []; + if (!is_array($settings)) { + $settings = []; + } + + // Обновляем переданные поля + foreach ($data['settings'] as $key => $value) { + $settings[$key] = $value; + } + + // active_button + if (isset($data['active_button'])) { + $settings['active_button'] = (bool) $data['active_button']; + unset($data['active_button']); + } + + $data['settings'] = $settings; + } + + // Преобразуем is_active в boolean + if (isset($data['is_active'])) { + $data['is_active'] = (bool) $data['is_active']; + } + + $slide->update($data); + + return $slide; + } +} diff --git a/app/Containers/Dashboard/Actions/Sliders/UpdateSliderAction.php b/app/Containers/Dashboard/Actions/Sliders/UpdateSliderAction.php new file mode 100644 index 0000000..0d05afe --- /dev/null +++ b/app/Containers/Dashboard/Actions/Sliders/UpdateSliderAction.php @@ -0,0 +1,15 @@ +update($data); + + return $slider; + } +} diff --git a/app/Containers/Dashboard/Actions/Sliders/UpdateSlidesOrderAction.php b/app/Containers/Dashboard/Actions/Sliders/UpdateSlidesOrderAction.php new file mode 100644 index 0000000..cd31a52 --- /dev/null +++ b/app/Containers/Dashboard/Actions/Sliders/UpdateSlidesOrderAction.php @@ -0,0 +1,15 @@ + $slideId) { + $slider->slides()->where('id', $slideId)->update(['sort' => $index + 1]); + } + } +} diff --git a/app/Containers/Dashboard/Actions/SubSections/AttachSubSectionToMainSectionAction.php b/app/Containers/Dashboard/Actions/SubSections/AttachSubSectionToMainSectionAction.php new file mode 100644 index 0000000..0605758 --- /dev/null +++ b/app/Containers/Dashboard/Actions/SubSections/AttachSubSectionToMainSectionAction.php @@ -0,0 +1,15 @@ +update(['main_section_id' => $mainSectionId]); + + return $subSection->fresh(); + } +} diff --git a/app/Containers/Dashboard/Actions/SubSections/CreateSubSectionAction.php b/app/Containers/Dashboard/Actions/SubSections/CreateSubSectionAction.php new file mode 100644 index 0000000..3926383 --- /dev/null +++ b/app/Containers/Dashboard/Actions/SubSections/CreateSubSectionAction.php @@ -0,0 +1,17 @@ + $data['title'], + 'slug' => $data['slug'], + 'main_section_id' => $data['main_section_id'] ?? null, + ]); + } +} diff --git a/app/Containers/Dashboard/Actions/SubSections/DeleteSubSectionAction.php b/app/Containers/Dashboard/Actions/SubSections/DeleteSubSectionAction.php new file mode 100644 index 0000000..b6e567d --- /dev/null +++ b/app/Containers/Dashboard/Actions/SubSections/DeleteSubSectionAction.php @@ -0,0 +1,13 @@ +delete(); + } +} diff --git a/app/Containers/Dashboard/Actions/SubSections/DetachSubSectionFromMainSectionAction.php b/app/Containers/Dashboard/Actions/SubSections/DetachSubSectionFromMainSectionAction.php new file mode 100644 index 0000000..7745a4c --- /dev/null +++ b/app/Containers/Dashboard/Actions/SubSections/DetachSubSectionFromMainSectionAction.php @@ -0,0 +1,15 @@ +update(['main_section_id' => null]); + + return $subSection->fresh(); + } +} diff --git a/app/Containers/Dashboard/Actions/SubSections/ListSubSectionsAction.php b/app/Containers/Dashboard/Actions/SubSections/ListSubSectionsAction.php new file mode 100644 index 0000000..caba1e6 --- /dev/null +++ b/app/Containers/Dashboard/Actions/SubSections/ListSubSectionsAction.php @@ -0,0 +1,27 @@ +with(['mainSection', 'pages']); + + if (!empty($filters['search'])) { + $query->where('title', 'like', '%' . $filters['search'] . '%'); + } + + if (!empty($filters['main_section_id'])) { + $query->where('main_section_id', $filters['main_section_id']); + } + + $subSections = $query->orderBy('sort')->paginate(15); + + return [ + 'subSections' => $subSections, + ]; + } +} diff --git a/app/Containers/Dashboard/Actions/SubSections/UpdateSubSectionAction.php b/app/Containers/Dashboard/Actions/SubSections/UpdateSubSectionAction.php new file mode 100644 index 0000000..bc1fcf0 --- /dev/null +++ b/app/Containers/Dashboard/Actions/SubSections/UpdateSubSectionAction.php @@ -0,0 +1,45 @@ +update([ + 'title' => $data['title'], + 'slug' => $data['slug'], + ]); + + if (isset($data['page_ids'])) { + $this->syncPages($subSection, $data['page_ids']); + } + + return $subSection->fresh(); + } + + private function syncPages(SubSection $subSection, array $pageIds): void + { + Page::query()->where('sub_section_id', '=', $subSection->id)->update(['sub_section_id' => null]); + Page::whereIn('id', $pageIds)->update(['sub_section_id' => $subSection->id]); + + $pages = Page::where('is_url', '=', false) + ->where('sub_section_id', '=', $subSection->id) + ->get(); + + if ($pages->isEmpty() || !$subSection->mainSection) { + return; + } + + $mainSectionSlug = $pages->first()->section->mainSection->slug; + + foreach ($pages as $page) { + if ($page->is_registered != true) { + $page->update(['path' => $mainSectionSlug . '/' . $subSection->slug . '/' . $page->slug]); + } + } + } +} diff --git a/app/Containers/Dashboard/Actions/UserDetails/CreateUserDetailAction.php b/app/Containers/Dashboard/Actions/UserDetails/CreateUserDetailAction.php new file mode 100644 index 0000000..9c10864 --- /dev/null +++ b/app/Containers/Dashboard/Actions/UserDetails/CreateUserDetailAction.php @@ -0,0 +1,15 @@ +delete(); + } +} diff --git a/app/Containers/Dashboard/Actions/UserDetails/UpdateUserDetailAction.php b/app/Containers/Dashboard/Actions/UserDetails/UpdateUserDetailAction.php new file mode 100644 index 0000000..cee37f1 --- /dev/null +++ b/app/Containers/Dashboard/Actions/UserDetails/UpdateUserDetailAction.php @@ -0,0 +1,14 @@ +update($data); + return $userDetail->fresh(); + } +} diff --git a/app/Containers/Dashboard/Actions/Users/CreateUserAction.php b/app/Containers/Dashboard/Actions/Users/CreateUserAction.php new file mode 100644 index 0000000..db99863 --- /dev/null +++ b/app/Containers/Dashboard/Actions/Users/CreateUserAction.php @@ -0,0 +1,43 @@ +generateUniqueSlug($data['name']); + + $user = User::create($data); + + // Назначение ролей если переданы + if (isset($data['roles']) && is_array($data['roles'])) { + $user->syncRoles($data['roles']); + } + + // Назначение разрешений если переданы + if (isset($data['permissions']) && is_array($data['permissions'])) { + $user->syncPermissions($data['permissions']); + } + + return $user->load(['roles', 'permissions']); + } + + private function generateUniqueSlug(string $name): string + { + $slug = Str::slug($name); + $count = 1; + $baseSlug = $slug; + + while (User::where('slug', $slug)->exists()) { + $slug = $baseSlug . '-' . $count; + $count++; + } + + return $slug; + } +} diff --git a/app/Containers/Dashboard/Actions/Users/DeleteUserAction.php b/app/Containers/Dashboard/Actions/Users/DeleteUserAction.php new file mode 100644 index 0000000..0e6c1dc --- /dev/null +++ b/app/Containers/Dashboard/Actions/Users/DeleteUserAction.php @@ -0,0 +1,13 @@ +delete(); + } +} diff --git a/app/Containers/Dashboard/Actions/Users/InviteUserAction.php b/app/Containers/Dashboard/Actions/Users/InviteUserAction.php new file mode 100644 index 0000000..7181081 --- /dev/null +++ b/app/Containers/Dashboard/Actions/Users/InviteUserAction.php @@ -0,0 +1,37 @@ +first(); + + if ($existingUser) { + return [ + 'success' => false, + 'message' => 'Данный пользователь уже существует в системе', + ]; + } + + // Создаем приглашение + $invitation = Invitation::create([ + 'email' => $email, + 'user_id' => $senderId, + ]); + + // Отправляем письмо + \Illuminate\Support\Facades\Mail::to($invitation->email)->send(new InvitationMail($invitation)); + + return [ + 'success' => true, + 'message' => 'Пользователь успешно приглашен', + 'invitation' => $invitation, + ]; + } +} diff --git a/app/Containers/Dashboard/Actions/Users/ListUsersAction.php b/app/Containers/Dashboard/Actions/Users/ListUsersAction.php new file mode 100644 index 0000000..ef890e3 --- /dev/null +++ b/app/Containers/Dashboard/Actions/Users/ListUsersAction.php @@ -0,0 +1,37 @@ +where(function ($q) use ($filters) { + $q->where('name', 'like', '%' . $filters['search'] . '%') + ->orWhere('email', 'like', '%' . $filters['search'] . '%'); + }); + } + + // Фильтр по роли + if (!empty($filters['role_id'])) { + $query->whereHas('roles', function ($q) use ($filters) { + $q->where('roles.id', $filters['role_id']); + }); + } + + $users = $query->orderBy('created_at', 'desc')->paginate(20)->withQueryString(); + + return [ + 'users' => $users, + 'filters' => $filters, + 'roles' => Role::with('permissions')->orderBy('name')->get(['id', 'name']), + ]; + } +} diff --git a/app/Containers/Dashboard/Actions/Users/UpdateUserAction.php b/app/Containers/Dashboard/Actions/Users/UpdateUserAction.php new file mode 100644 index 0000000..faf6ed8 --- /dev/null +++ b/app/Containers/Dashboard/Actions/Users/UpdateUserAction.php @@ -0,0 +1,55 @@ +name) { + $data['slug'] = $this->generateUniqueSlug($data['name']); + } + + // Если пароль пустой или null, удаляем из данных + if (empty($data['password'])) { + unset($data['password']); + } + + // Извлекаем роли и пермисшены — они не в $fillable и не колонки таблицы + $roles = $data['roles'] ?? null; + $permissions = $data['permissions'] ?? null; + unset($data['roles'], $data['permissions']); + + $user->update($data); + + // Синхронизация ролей + if ($roles !== null && is_array($roles)) { + $user->syncRoles($roles); + } + + // Синхронизация разрешений + if ($permissions !== null && is_array($permissions)) { + $user->syncPermissions($permissions); + } + + return $user->fresh(['roles', 'permissions']); + } + + private function generateUniqueSlug(string $name): string + { + $slug = Str::slug($name); + $count = 1; + $baseSlug = $slug; + + while (User::where('slug', $slug)->exists()) { + $slug = $baseSlug . '-' . $count; + $count++; + } + + return $slug; + } +} diff --git a/app/Containers/Dashboard/Tasks/CallAiServiceForFileSelectionTask.php b/app/Containers/Dashboard/Tasks/AI/CallAiServiceForFileSelectionTask.php similarity index 98% rename from app/Containers/Dashboard/Tasks/CallAiServiceForFileSelectionTask.php rename to app/Containers/Dashboard/Tasks/AI/CallAiServiceForFileSelectionTask.php index b79254d..94dd654 100644 --- a/app/Containers/Dashboard/Tasks/CallAiServiceForFileSelectionTask.php +++ b/app/Containers/Dashboard/Tasks/AI/CallAiServiceForFileSelectionTask.php @@ -1,6 +1,6 @@ extractDataFromBlock($block); + } + + // Удаляем лишние пробелы и переносы строк + $result = preg_replace('/\s+/', ' ', $result); + $result = trim($result); + + return strtolower($result); + } + + private function extractDataFromBlock(array $block): string + { + $data = ''; + + switch ($block['type']) { + case 'paragraph': + case 'heading': + $data .= strip_tags($block['data']['content'] ?? '') . ' '; + break; + + case 'files': + if (isset($block['data']['file']) && is_array($block['data']['file'])) { + foreach ($block['data']['file'] as $file) { + $data .= ($file['title'] ?? '') . ' '; + } + } + break; + + case 'person': + $data .= ($block['data']['name'] ?? '') . ' '; + break; + + case 'stepper': + $data .= ($block['data']['step_name'] ?? '') . ' '; + if (isset($block['data']['steps']) && is_array($block['data']['steps'])) { + foreach ($block['data']['steps'] as $step) { + $data .= ($step['title'] ?? '') . ' '; + $data .= strip_tags($step['content'] ?? '') . ' '; + } + } + break; + + case 'tabs': + if (isset($block['data']['tab']) && is_array($block['data']['tab'])) { + foreach ($block['data']['tab'] as $item) { + if (isset($item['content']) && is_array($item['content'])) { + foreach ($item['content'] as $nestedBlock) { + $data .= $this->extractDataFromBlock($nestedBlock); + } + } + } + } + break; + } + + return $data; + } +} diff --git a/app/Containers/Dashboard/Tasks/ConnectToImapTask.php b/app/Containers/Dashboard/Tasks/ConnectToImapTask.php deleted file mode 100644 index bdf0dd9..0000000 --- a/app/Containers/Dashboard/Tasks/ConnectToImapTask.php +++ /dev/null @@ -1,106 +0,0 @@ - $accountName, - 'host' => $imapConfig['accounts'][$accountName]['host'] ?? 'unknown', - ]); - - try { - // Создаём ClientManager с явным конфигом - $clientManager = new ClientManager($imapConfig); - $client = $clientManager->account($accountName); - $client->connect(); - - Log::info('[ConnectToImapTask] Успешное подключение к IMAP', [ - 'account' => $accountName, - ]); - - return $client; - } catch (\Exception $e) { - Log::error('[ConnectToImapTask] Ошибка подключения к IMAP', [ - 'account' => $accountName, - 'error' => $e->getMessage(), - ]); - - throw EmailFetchException::connectionFailed($e->getMessage()); - } - } - - /** - * Получить папку - * - * @param Client $client IMAP клиент - * @param string $folderName Имя папки - * @return Folder - * @throws EmailFetchException - */ - public function getFolder(Client $client, string $folderName): Folder - { - Log::info('[ConnectToImapTask] Получение папки', [ - 'folder' => $folderName, - ]); - - try { - // Пробуем получить папку напрямую - $folder = $client->getFolder($folderName); - - // Если не получилось, ищем в списке папок - if (!$folder) { - $folders = $client->getFolders(); - foreach ($folders as $f) { - if ($f->name === $folderName || $f->path === $folderName) { - $folder = $f; - break; - } - } - } - - if (!$folder) { - throw EmailFetchException::folderNotFound($folderName); - } - - Log::info('[ConnectToImapTask] Папка получена успешно', [ - 'folder' => $folderName, - 'fullName' => $folder->full_name ?? $folder->name ?? $folderName, - ]); - - return $folder; - } catch (EmailFetchException $e) { - throw $e; - } catch (\Exception $e) { - Log::error('[ConnectToImapTask] Ошибка получения папки', [ - 'folder' => $folderName, - 'error' => $e->getMessage(), - ]); - - throw EmailFetchException::folderNotFound($folderName); - } - } -} diff --git a/app/Containers/Dashboard/Tasks/Content/GenerateSearchDataTask.php b/app/Containers/Dashboard/Tasks/Content/GenerateSearchDataTask.php new file mode 100644 index 0000000..df77cf6 --- /dev/null +++ b/app/Containers/Dashboard/Tasks/Content/GenerateSearchDataTask.php @@ -0,0 +1,62 @@ +getDataFromBlocks($block); + } + + // Remove extra whitespace and newlines + $result = preg_replace('/\s+/', ' ', $result); + $result = trim($result); + + return strtolower($result); + } + + private function getDataFromBlocks($block): string + { + $data = ''; + + switch ($block['type']) { + case 'paragraph': + $data .= strip_tags($block['data']['content']) . ' '; + break; + case 'heading': + $data .= strip_tags($block['data']['content']) . ' '; + break; + case 'files': + foreach ($block['data']['file'] as $file) { + $data .= $file['title'] . ' '; + } + break; + case 'person': + $data .= $block['data']['name'] . ' '; + break; + case 'stepper': + $data .= $block['data']['step_name'] . ' '; + foreach ($block['data']['steps'] as $step) { + $data .= $step['title'] . ' '; + $data .= strip_tags($step['content']) . ' '; + } + break; + case 'tabs': + foreach ($block['data']['tab'] as $item) { + foreach ($item['content'] as $block) { + $data .= $this->getDataFromBlocks($block); + } + } + break; + } + + return $data; + } +} diff --git a/app/Containers/Dashboard/Tasks/DownloadAttachmentsTask.php b/app/Containers/Dashboard/Tasks/DownloadAttachmentsTask.php deleted file mode 100644 index e5a09c1..0000000 --- a/app/Containers/Dashboard/Tasks/DownloadAttachmentsTask.php +++ /dev/null @@ -1,190 +0,0 @@ - - * @throws EmailFetchException - */ - public function run(object $message, ?string $disk = null): array - { - $disk = $disk ?? config('email-news.attachments_folder', 'email_attachments'); - - Log::info('[DownloadAttachmentsTask] Начало загрузки вложений', [ - 'message_id' => $message->getMessageId(), - 'disk' => $disk, - ]); - - $attachments = $message->getAttachments(); - - if (empty($attachments)) { - Log::warning('[DownloadAttachmentsTask] Вложения не найдены'); - throw EmailFetchException::noAttachmentsFound(); - } - - Log::info('[DownloadAttachmentsTask] Найдено вложений', [ - 'count' => count($attachments), - ]); - - $savedAttachments = []; - $maxSize = config('email-news.max_attachment_size', 41943040); // 40MB по умолчанию - - /** @var Attachment $attachment */ - foreach ($attachments as $attachment) { - try { - // Проверяем размер - if ($maxSize > 0 && $attachment->getSize() > $maxSize) { - Log::warning('[DownloadAttachmentsTask] Вложение превышает максимальный размер', [ - 'filename' => $attachment->getName(), - 'size' => $attachment->getSize(), - 'max_size' => $maxSize, - ]); - continue; - } - - // Сохраняем вложение - $savedPath = $this->saveAttachment($attachment, $disk); - - if ($savedPath) { - // Декодируем MIME-имя для корректного определения расширения - $decodedFilename = $this->decodeMimeFilename($attachment->getName()); - - $savedAttachments[] = EmailAttachmentData::fromFile( - path: $savedPath, - originalFilename: $decodedFilename, - mimeType: $attachment->getContentType(), - size: $attachment->getSize(), - contentId: $attachment->getContentId(), - ); - - Log::info('[DownloadAttachmentsTask] Вложение сохранено', [ - 'filename' => $attachment->getName(), - 'path' => $savedPath, - 'size' => $attachment->getSize(), - ]); - } - } catch (\Exception $e) { - Log::error('[DownloadAttachmentsTask] Ошибка сохранения вложения', [ - 'filename' => $attachment->getName(), - 'error' => $e->getMessage(), - ]); - // Продолжаем обработку остальных вложений - } - } - - if (empty($savedAttachments)) { - Log::error('[DownloadAttachmentsTask] Не удалось сохранить ни одно вложение'); - throw EmailFetchException::noAttachmentsFound(); - } - - Log::info('[DownloadAttachmentsTask] Загрузка вложений завершена', [ - 'saved_count' => count($savedAttachments), - ]); - - return $savedAttachments; - } - - /** - * Сохранить вложение на диск - * - * @param Attachment $attachment Вложение - * @param string $disk Диск для сохранения - * @return string|null Путь к сохранённому файлу - */ - private function saveAttachment(Attachment $attachment, string $disk): ?string - { - $filename = $this->generateUniqueFilename($attachment->getName()); - $savePath = storage_path('app/' . $disk); - - // Создаём директорию, если не существует - if (!is_dir($savePath)) { - mkdir($savePath, 0755, true); - } - - // Сохраняем вложение (метод save принимает только путь и имя файла) - try { - $savedPath = $attachment->save($savePath, $filename); - - if ($savedPath) { - // Возвращаем относительный путь для сохранения в БД - return $disk . '/' . $filename; - } - } catch (\Exception $e) { - Log::error('[DownloadAttachmentsTask:saveAttachment] Ошибка сохранения', [ - 'filename' => $attachment->getName(), - 'error' => $e->getMessage(), - ]); - } - - return null; - } - - /** - * Сгенерировать уникальное имя файла - * - * @param string $originalName Оригинальное имя файла - * @return string - */ - private function generateUniqueFilename(string $originalName): string - { - $decodedName = $this->decodeMimeFilename($originalName); - - // Очищаем имя от специальных символов - $decodedName = preg_replace('/[^a-zA-Z0-9_\-\p{L}.]/u', '_', $decodedName); - - // Удаляем множественные подчёркивания - $decodedName = preg_replace('/_+/', '_', $decodedName); - - // Получаем расширение - $extension = strtolower(pathinfo($decodedName, PATHINFO_EXTENSION)); - - // Если расширение пустое, пробуем определить из оригинального имени - if (empty($extension) && str_contains($originalName, '.docx')) { - $extension = 'docx'; - } elseif (empty($extension) && str_contains($originalName, '.doc')) { - $extension = 'doc'; - } elseif (empty($extension) && str_contains($originalName, '.pdf')) { - $extension = 'pdf'; - } - - // Генерируем уникальное имя - $basename = pathinfo($decodedName, PATHINFO_FILENAME); - $basename = mb_substr($basename, 0, 100); // Ограничиваем длину - - return $basename . '_' . time() . '_' . bin2hex(random_bytes(4)) . '.' . ($extension ?: 'bin'); - } - - /** - * Декодировать MIME-кодированное имя файла - * - * @param string $filename Имя файла в MIME-кодировке - * @return string Декодированное имя файла - */ - private function decodeMimeFilename(string $filename): string - { - // Декодируем MIME-кодировку (=?UTF-8?B?...?=) - $decodedName = mb_decode_mimeheader($filename) ?: $filename; - - // Если не декодировалось, пробуем другой метод - if ($decodedName === $filename && str_contains($filename, '=?')) { - $decodedName = iconv_mime_decode($filename, ICONV_MIME_DECODE_CONTINUE_ON_ERROR, 'UTF-8') ?: $filename; - } - - return $decodedName; - } -} diff --git a/app/Containers/Dashboard/Tasks/FetchUnreadEmailsTask.php b/app/Containers/Dashboard/Tasks/FetchUnreadEmailsTask.php deleted file mode 100644 index 08832d4..0000000 --- a/app/Containers/Dashboard/Tasks/FetchUnreadEmailsTask.php +++ /dev/null @@ -1,60 +0,0 @@ - $folder->full_name ?? $folder->name ?? 'unknown', - ]); - - try { - // Получаем все непрочитанные сообщения - $messages = $folder->messages()->unseen()->get(); - - $emails = []; - foreach ($messages as $message) { - $from = $message->getFrom()[0] ?? null; - $subject = $message->getSubject(); - $date = $message->getDate(); - - $emails[] = [ - 'message' => $message, - 'message_id' => $message->getMessageId(), - 'from_email' => $from?->mail ?? null, - 'from_name' => $from?->name ?? null, - 'subject' => $subject, - 'date' => $date, - 'has_attachments' => $message->hasAttachments(), - ]; - } - - Log::info('[FetchUnreadEmailsTask] Получены письма', [ - 'count' => count($emails), - 'folder' => $folder->full_name ?? $folder->name ?? 'unknown', - ]); - - return $emails; - } catch (\Exception $e) { - Log::error('[FetchUnreadEmailsTask] Ошибка получения писем', [ - 'error' => $e->getMessage(), - ]); - - return []; - } - } -} diff --git a/app/Containers/Dashboard/Tasks/CompressImageTask.php b/app/Containers/Dashboard/Tasks/Files/CompressImageTask.php similarity index 97% rename from app/Containers/Dashboard/Tasks/CompressImageTask.php rename to app/Containers/Dashboard/Tasks/Files/CompressImageTask.php index 1efbe95..9f7f1b1 100644 --- a/app/Containers/Dashboard/Tasks/CompressImageTask.php +++ b/app/Containers/Dashboard/Tasks/Files/CompressImageTask.php @@ -1,6 +1,6 @@ $this->formatFileSize($newSize), 'compression_ratio' => round((1 - $newSize / $file->getSize()) * 100, 2) . '%', 'temp_path' => $tempPath, + 'memory_usage' => round(memory_get_usage(true) / 1024 / 1024, 2) . ' MB', ]); // Создаём новый UploadedFile из сжатого diff --git a/app/Containers/Dashboard/Tasks/Files/UploadFileTask.php b/app/Containers/Dashboard/Tasks/Files/UploadFileTask.php new file mode 100644 index 0000000..de31b72 --- /dev/null +++ b/app/Containers/Dashboard/Tasks/Files/UploadFileTask.php @@ -0,0 +1,35 @@ + путь в storage, 'url' => публичный URL, 'original_name' => оригинальное имя] + */ + public function run(UploadedFile $file): array + { + $originalFileNameWithExtension = $file->getClientOriginalName(); + $originalFileName = pathinfo($originalFileNameWithExtension, PATHINFO_FILENAME); + $extension = $file->getClientOriginalExtension(); + + $sluggedFileName = Str::slug($originalFileName); + $uniqueFileName = $sluggedFileName . '-' . md5(uniqid(rand(), true)) . '.' . $extension; + + $path = $file->storeAs('files', $uniqueFileName, 'public'); + $url = Storage::url($path); + + return [ + 'path' => $path, + 'url' => $url, + 'original_name' => $originalFileNameWithExtension, + ]; + } +} diff --git a/app/Containers/Dashboard/Tasks/FilterBySenderTask.php b/app/Containers/Dashboard/Tasks/FilterBySenderTask.php deleted file mode 100644 index ce22bb2..0000000 --- a/app/Containers/Dashboard/Tasks/FilterBySenderTask.php +++ /dev/null @@ -1,95 +0,0 @@ - !empty($email)); - - // Если список пуст, используем editor_email - if (empty($allowedSenders)) { - $editorEmail = config('email-news.editor_email'); - if ($editorEmail) { - $allowedSenders = [$editorEmail]; - } - } - - Log::info('[FilterBySenderTask] Фильтрация писем', [ - 'total_emails' => count($emails), - 'allowed_senders' => $allowedSenders, - ]); - - if (empty($allowedSenders)) { - Log::warning('[FilterBySenderTask] Не указан разрешённый отправитель, пропускаем все письма'); - return []; - } - - $filtered = []; - $skippedCount = 0; - - foreach ($emails as $email) { - $fromEmail = $email['from_email']; - - // Проверяем, есть ли отправитель в whitelist - if (!in_array($fromEmail, $allowedSenders, true)) { - $skippedCount++; - - // Логируем только если включено логирование - if (config('email-news.log_skipped_emails', true)) { - Log::debug('[FilterBySenderTask] Пропущено письмо от неразрешённого отправителя', [ - 'from' => $fromEmail, - 'subject' => $email['subject'], - 'date' => $email['date'], - ]); - } - continue; - } - - $filtered[] = $email; - } - - Log::info('[FilterBySenderTask] Фильтрация завершена', [ - 'total' => count($emails), - 'filtered' => count($filtered), - 'skipped' => $skippedCount, - ]); - - return $filtered; - } - - /** - * Проверить конкретный email на разрешение - * - * @param string $email Email для проверки - * @return bool - */ - public function isAllowed(string $email): bool - { - $allowedSenders = config('email-news.allowed_senders', []); - $allowedSenders = array_filter($allowedSenders, fn($e) => !empty($e)); - - if (empty($allowedSenders)) { - $allowedSenders = [config('email-news.editor_email')]; - } - - return in_array($email, $allowedSenders, true); - } -} diff --git a/app/Containers/Dashboard/Tasks/MarkEmailAsReadTask.php b/app/Containers/Dashboard/Tasks/MarkEmailAsReadTask.php deleted file mode 100644 index 4b68745..0000000 --- a/app/Containers/Dashboard/Tasks/MarkEmailAsReadTask.php +++ /dev/null @@ -1,74 +0,0 @@ - $message->getMessageId(), - 'subject' => $message->getSubject(), - ]); - - try { - $message->setFlag('Seen'); - - Log::info('[MarkEmailAsReadTask] Письмо помечено как прочитанное'); - - return true; - } catch (\Exception $e) { - Log::error('[MarkEmailAsReadTask] Ошибка пометки письма', [ - 'error' => $e->getMessage(), - ]); - - return false; - } - } - - /** - * Пометить письмо как прочитанное и переместить в другую папку - * - * @param object $message IMAP сообщение - * @param string $targetFolder Целевая папка - * @return bool - */ - public function markAndMove(object $message, string $targetFolder): bool - { - Log::info('[MarkEmailAsReadTask] Пометка и перемещение письма', [ - 'message_id' => $message->getMessageId(), - 'target_folder' => $targetFolder, - ]); - - try { - // Помечаем как прочитанное - $message->setFlag('Seen'); - - // Перемещаем в другую папку - $message->moveToFolder($targetFolder); - - Log::info('[MarkEmailAsReadTask] Письмо обработано и перемещено', [ - 'target_folder' => $targetFolder, - ]); - - return true; - } catch (\Exception $e) { - Log::error('[MarkEmailAsReadTask] Ошибка обработки письма', [ - 'error' => $e->getMessage(), - ]); - - return false; - } - } -} diff --git a/app/Containers/Dashboard/Tasks/Posts/BulkDeletePostsTask.php b/app/Containers/Dashboard/Tasks/Posts/BulkDeletePostsTask.php new file mode 100644 index 0000000..2c84541 --- /dev/null +++ b/app/Containers/Dashboard/Tasks/Posts/BulkDeletePostsTask.php @@ -0,0 +1,13 @@ +delete(); + } +} diff --git a/app/Containers/Dashboard/Tasks/Posts/BulkUpdatePostStatusTask.php b/app/Containers/Dashboard/Tasks/Posts/BulkUpdatePostStatusTask.php new file mode 100644 index 0000000..31ee144 --- /dev/null +++ b/app/Containers/Dashboard/Tasks/Posts/BulkUpdatePostStatusTask.php @@ -0,0 +1,21 @@ + $status]; + + if ($publishAt !== null) { + $data['publish_at'] = $publishAt; + } + + return Post::whereIn('id', $ids)->update($data); + } +} diff --git a/app/Containers/Dashboard/Tasks/CreatePostFromAiDataTask.php b/app/Containers/Dashboard/Tasks/Posts/CreatePostFromAiDataTask.php similarity index 98% rename from app/Containers/Dashboard/Tasks/CreatePostFromAiDataTask.php rename to app/Containers/Dashboard/Tasks/Posts/CreatePostFromAiDataTask.php index f7e4b33..1c941f3 100644 --- a/app/Containers/Dashboard/Tasks/CreatePostFromAiDataTask.php +++ b/app/Containers/Dashboard/Tasks/Posts/CreatePostFromAiDataTask.php @@ -1,6 +1,6 @@ postDataProcessor->processCreate($data); + return Post::create($processedData); + } + + /** + * Генерирует уникальный slug + */ + public function generateUniqueSlug(string $baseSlug): string + { + $slug = $baseSlug; + $count = 1; + + while (Post::where('slug', $slug)->exists()) { + $slug = $baseSlug . '-' . $count; + $count++; + } + + return $slug; + } +} diff --git a/app/Containers/Dashboard/Tasks/Posts/DeletePostTask.php b/app/Containers/Dashboard/Tasks/Posts/DeletePostTask.php new file mode 100644 index 0000000..3bb4cff --- /dev/null +++ b/app/Containers/Dashboard/Tasks/Posts/DeletePostTask.php @@ -0,0 +1,13 @@ +delete(); + } +} diff --git a/app/Containers/Dashboard/Tasks/GetAiPreparedPostsTask.php b/app/Containers/Dashboard/Tasks/Posts/GetAiPreparedPostsTask.php similarity index 94% rename from app/Containers/Dashboard/Tasks/GetAiPreparedPostsTask.php rename to app/Containers/Dashboard/Tasks/Posts/GetAiPreparedPostsTask.php index b6af83d..4f02a3d 100644 --- a/app/Containers/Dashboard/Tasks/GetAiPreparedPostsTask.php +++ b/app/Containers/Dashboard/Tasks/Posts/GetAiPreparedPostsTask.php @@ -1,6 +1,6 @@ Category::all(['id', 'title']), + 'sliders' => Slider::where('is_active', true)->get(['id', 'title']), + 'statuses' => PostStatus::cases(), + ]; + } +} diff --git a/app/Containers/Dashboard/Tasks/Posts/HandlePostSliderTask.php b/app/Containers/Dashboard/Tasks/Posts/HandlePostSliderTask.php new file mode 100644 index 0000000..bce0d19 --- /dev/null +++ b/app/Containers/Dashboard/Tasks/Posts/HandlePostSliderTask.php @@ -0,0 +1,57 @@ +slide?->delete(); + return; + } + + $data = [ + 'is_active' => $slideData['is_active'] ?? false, + 'start_time' => $post->publish_at ?? Carbon::now(), + 'title' => $slideData['title'] ?? $post->title, + 'description' => $slideData['content'] ?? $post->preview_text, + 'image' => $slideData['image']['url'] ?? null, + 'color_theme' => $slideData['color_theme'] ?? '#ffffff', + ]; + + // Добавляем настройки слайда + if (isset($slideData['settings'])) { + $data = array_merge($data, $slideData['settings']); + } + + $sliderDTO = MainSliderDTO::fromArray($data); + $sliderService = new PostSliderService($sliderDTO, $post); + + // Обновляем или создаем слайдер + if ($post->slide && !$isCreate) { + $sliderService->update(); + } else { + $sliderService->create(); + } + } +} diff --git a/app/Containers/Dashboard/Tasks/Posts/ListPostsTask.php b/app/Containers/Dashboard/Tasks/Posts/ListPostsTask.php new file mode 100644 index 0000000..cdccfe3 --- /dev/null +++ b/app/Containers/Dashboard/Tasks/Posts/ListPostsTask.php @@ -0,0 +1,25 @@ +orderBy('publish_at', 'desc'); + + if (!empty($filters['status'])) { + $query->where('status', $filters['status']); + } + + if (!empty($filters['search'])) { + $query->where('title', 'like', '%' . $filters['search'] . '%'); + } + + return $query->paginate($perPage); + } +} diff --git a/app/Containers/Dashboard/Tasks/Posts/PublishPostToVkTask.php b/app/Containers/Dashboard/Tasks/Posts/PublishPostToVkTask.php new file mode 100644 index 0000000..34c1d92 --- /dev/null +++ b/app/Containers/Dashboard/Tasks/Posts/PublishPostToVkTask.php @@ -0,0 +1,41 @@ +where('post_id', $post->id) + ->first(); + + if ($postRelation) { + $publisher->update(['vk' => true], $post); + return; + } + } + + $publisher->publish(['vk' => true], $post); + } +} diff --git a/app/Containers/Dashboard/Tasks/Posts/SendPostNotificationTask.php b/app/Containers/Dashboard/Tasks/Posts/SendPostNotificationTask.php new file mode 100644 index 0000000..cc400ce --- /dev/null +++ b/app/Containers/Dashboard/Tasks/Posts/SendPostNotificationTask.php @@ -0,0 +1,40 @@ +send($post); + return; + } + + // При обновлении отправляем только при смене статуса + $status = $newStatus ?? $post->status; + + if ($status instanceof PostStatus) { + if ($status === PostStatus::PUBLISHED) { + $notificationService->sendSuccessNotification($post); + } elseif ($status === PostStatus::REJECTED) { + $notificationService->sendDeniedNotification($post); + } + } + } +} diff --git a/app/Containers/Dashboard/Tasks/Posts/UpdatePostTask.php b/app/Containers/Dashboard/Tasks/Posts/UpdatePostTask.php new file mode 100644 index 0000000..df5f2ca --- /dev/null +++ b/app/Containers/Dashboard/Tasks/Posts/UpdatePostTask.php @@ -0,0 +1,32 @@ +postDataProcessor->processUpdate([ + ...$post->toArray(), + ...$data, + ]); + + $post->update($processedData); + + return $post->fresh(); + } +} diff --git a/app/Containers/Dashboard/Tasks/Sliders/UploadSlideImageTask.php b/app/Containers/Dashboard/Tasks/Sliders/UploadSlideImageTask.php new file mode 100644 index 0000000..bb633f9 --- /dev/null +++ b/app/Containers/Dashboard/Tasks/Sliders/UploadSlideImageTask.php @@ -0,0 +1,24 @@ +getClientOriginalExtension(); + $path = $file->storeAs('slides', $filename, 'public'); + + // Сохраняем только путь файла (как Filament) + return [ + 'url' => $path, + 'path' => $path, + 'name' => $file->getClientOriginalName(), + 'size' => $file->getSize(), + 'mime' => $file->getMimeType(), + ]; + } +} diff --git a/app/Containers/Dashboard/Tasks/Stats/GetDashboardStatsTask.php b/app/Containers/Dashboard/Tasks/Stats/GetDashboardStatsTask.php new file mode 100644 index 0000000..8122a08 --- /dev/null +++ b/app/Containers/Dashboard/Tasks/Stats/GetDashboardStatsTask.php @@ -0,0 +1,89 @@ +clone()->subWeek(); + + return [ + 'posts' => $this->getPostsStats($weekAgo), + 'schedules' => $this->getSchedulesStats($weekAgo), + 'educational_groups' => $this->getEducationalGroupsStats($weekAgo), + 'sliders' => $this->getSlidersStats($weekAgo), + ]; + } + + private function getPostsStats($weekAgo): array + { + $stats = Post::selectRaw(' + COUNT(*) as total, + SUM(CASE WHEN created_at >= ? THEN 1 ELSE 0 END) as week, + SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as verification, + SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as published, + SUM(CASE WHEN status = ? THEN 1 ELSE 0 END) as rejected + ', [ + $weekAgo, + PostStatus::VERIFICATION, + PostStatus::PUBLISHED, + PostStatus::REJECTED, + ])->first(); + + return [ + 'total' => (int) $stats->total, + 'week' => (int) $stats->week, + 'verification' => (int) $stats->verification, + 'published' => (int) $stats->published, + 'rejected' => (int) $stats->rejected, + ]; + } + + private function getSchedulesStats($weekAgo): array + { + $stats = Schedule::selectRaw(' + COUNT(*) as total, + SUM(CASE WHEN created_at >= ? THEN 1 ELSE 0 END) as week + ', [$weekAgo])->first(); + + return [ + 'total' => (int) $stats->total, + 'week' => (int) $stats->week, + ]; + } + + private function getEducationalGroupsStats($weekAgo): array + { + $stats = EducationalGroup::selectRaw(' + COUNT(*) as total, + SUM(CASE WHEN created_at >= ? THEN 1 ELSE 0 END) as week + ', [$weekAgo])->first(); + + return [ + 'total' => (int) $stats->total, + 'week' => (int) $stats->week, + ]; + } + + private function getSlidersStats($weekAgo): array + { + $stats = Slider::selectRaw(' + COUNT(*) as total, + SUM(CASE WHEN created_at >= ? THEN 1 ELSE 0 END) as week + ', [$weekAgo])->first(); + + return [ + 'total' => (int) $stats->total, + 'week' => (int) $stats->week, + ]; + } +} diff --git a/app/Containers/Dashboard/Tasks/Stats/GetRecentActivityTask.php b/app/Containers/Dashboard/Tasks/Stats/GetRecentActivityTask.php new file mode 100644 index 0000000..b4c494d --- /dev/null +++ b/app/Containers/Dashboard/Tasks/Stats/GetRecentActivityTask.php @@ -0,0 +1,55 @@ + Post::query() + ->whereNotNull('user_id') + ->with(['category', 'author']) + ->latest() + ->limit(self::RECENT_POSTS_LIMIT) + ->get([ + 'id', + 'title', + 'slug', + 'status', + 'category_id', + 'user_id', + 'created_at', + 'updated_at', + ]), + 'recent_schedules' => Schedule::query() + ->with('educationalGroup') + ->latest() + ->limit(self::RECENT_SCHEDULES_LIMIT) + ->get([ + 'id', + 'file', + 'educational_group_id', + 'created_at', + ]), + 'recent_sliders' => Slider::query() + ->withCount('slides') + ->latest() + ->limit(self::RECENT_SLIDERS_LIMIT) + ->get([ + 'id', + 'title', + 'slug', + 'created_at', + ]), + ]; + } +} diff --git a/app/Containers/Dashboard/UI/WEB/Controllers/AcademicJournalController.php b/app/Containers/Dashboard/UI/WEB/Controllers/AcademicJournalController.php new file mode 100644 index 0000000..5ba4eec --- /dev/null +++ b/app/Containers/Dashboard/UI/WEB/Controllers/AcademicJournalController.php @@ -0,0 +1,110 @@ +only(['search']); + + $data = $this->listAcademicJournalsAction->run($filters); + + return Inertia::render('Dashboard/AcademicJournals/Index', $data); + } + + /** + * Show the form for creating a new journal + */ + public function create(): Response + { + return Inertia::render('Dashboard/AcademicJournals/Create'); + } + + /** + * Store a newly created journal + */ + public function store(StoreAcademicJournalRequest $request): RedirectResponse + { + try { + $validated = $request->validated(); + + $this->createAcademicJournalAction->run($validated); + + return redirect()->route('dashboard.academic-journals.index') + ->with('success', 'Научный журнал успешно создан!'); + } catch (\Exception $e) { + return back() + ->withInput() + ->with('error', 'Ошибка при создании журнала: ' . $e->getMessage()); + } + } + + /** + * Show the form for editing the specified journal + */ + public function edit(AcademicJournal $academicJournal): Response + { + return Inertia::render('Dashboard/AcademicJournals/Edit', [ + 'journal' => $academicJournal, + ]); + } + + /** + * Update the specified journal + */ + public function update(UpdateAcademicJournalRequest $request, AcademicJournal $academicJournal): RedirectResponse + { + try { + $validated = $request->validated(); + + $this->updateAcademicJournalAction->run($academicJournal, $validated); + + return redirect()->route('dashboard.academic-journals.index') + ->with('success', 'Научный журнал успешно обновлен!'); + } catch (\Exception $e) { + return back() + ->withInput() + ->with('error', 'Ошибка при обновлении журнала: ' . $e->getMessage()); + } + } + + /** + * Remove the specified journal + */ + public function destroy(AcademicJournal $academicJournal): RedirectResponse + { + try { + $this->deleteAcademicJournalAction->run($academicJournal); + + return redirect()->route('dashboard.academic-journals.index') + ->with('success', 'Научный журнал успешно удален!'); + } catch (\Exception $e) { + return back() + ->with('error', 'Ошибка при удалении журнала: ' . $e->getMessage()); + } + } +} diff --git a/app/Containers/Dashboard/UI/WEB/Controllers/AdditionalEducationController.php b/app/Containers/Dashboard/UI/WEB/Controllers/AdditionalEducationController.php new file mode 100644 index 0000000..4b30b60 --- /dev/null +++ b/app/Containers/Dashboard/UI/WEB/Controllers/AdditionalEducationController.php @@ -0,0 +1,165 @@ +only(['search', 'category_id', 'form_education', 'is_active']); + + $data = $this->listAdditionalEducationsAction->run($filters); + + return Inertia::render('Dashboard/AdditionalEducations/Index', $data); + } + + /** + * Показывает форму создания программы + */ + public function create(): \Inertia\Response + { + $data = $this->listAdditionalEducationsAction->run([]); + + return Inertia::render('Dashboard/AdditionalEducations/Create', [ + 'categories' => $data['categories'], + 'educationForms' => $data['educationForms'], + ]); + } + + /** + * Создает новую программу дополнительного образования + */ + public function store(StoreAdditionalEducationRequest $request): RedirectResponse + { + try { + $validated = $request->validated(); + + $education = $this->createAdditionalEducationAction->run($validated); + + $this->createSeo($education); + + return redirect()->route('dashboard.additional-educations.index') + ->with('success', 'Программа дополнительного образования успешно создана!'); + } catch (\Exception $e) { + return back() + ->withInput() + ->with('error', 'Ошибка при создании программы: ' . $e->getMessage()); + } + } + + /** + * Показывает форму редактирования программы + */ + public function edit(AdditionalEducation $additionalEducation): \Inertia\Response + { + $additionalEducation->load(['category', 'seo']); + + $data = $this->listAdditionalEducationsAction->run([]); + + return Inertia::render('Dashboard/AdditionalEducations/Edit', [ + 'education' => $additionalEducation, + 'categories' => $data['categories'], + 'educationForms' => $data['educationForms'], + ]); + } + + /** + * Обновляет существующую программу дополнительного образования + */ + public function update(UpdateAdditionalEducationRequest $request, AdditionalEducation $additionalEducation): RedirectResponse + { + try { + $validated = $request->validated(); + + $this->updateAdditionalEducationAction->run($additionalEducation, $validated); + + $this->updateSeo($additionalEducation); + + return redirect()->route('dashboard.additional-educations.index') + ->with('success', 'Программа дополнительного образования успешно обновлена!'); + } catch (\Exception $e) { + return back() + ->withInput() + ->with('error', 'Ошибка при обновлении программы: ' . $e->getMessage()); + } + } + + /** + * Удаляет программу дополнительного образования + */ + public function destroy(AdditionalEducation $additionalEducation): RedirectResponse + { + try { + $this->deleteAdditionalEducationAction->run($additionalEducation); + + return redirect()->route('dashboard.additional-educations.index') + ->with('success', 'Программа дополнительного образования успешно удалена!'); + } catch (\Exception $e) { + return back() + ->with('error', 'Ошибка при удалении программы: ' . $e->getMessage()); + } + } + + /** + * Генерирует и создает SEO данные + */ + private function createSeo(AdditionalEducation $record): void + { + $seoData = $this->generateSeoData($record); + $record->seo()->create($seoData); + } + + /** + * Обновляет SEO данные + */ + private function updateSeo(AdditionalEducation $record): void + { + if ($record->seo()->exists()) { + $record->seo()->update($this->generateSeoData($record)); + } else { + $this->createSeo($record); + } + } + + /** + * Генерирует SEO данные из записи + */ + private function generateSeoData(AdditionalEducation $record): array + { + return $this->seoGeneratorService->generate([ + 'title' => $record instanceof SeoTitleInterface + ? $record->getSeoTitle() + : $record->title, + 'content' => $record instanceof SeoDescriptionInterface + ? $record->getSeoDescription() + : ($record->content ?? []), + 'preview' => $record->preview ?? null, + ]); + } +} diff --git a/app/Containers/Dashboard/UI/WEB/Controllers/AdditionalEducations/CategoryController.php b/app/Containers/Dashboard/UI/WEB/Controllers/AdditionalEducations/CategoryController.php new file mode 100644 index 0000000..552129e --- /dev/null +++ b/app/Containers/Dashboard/UI/WEB/Controllers/AdditionalEducations/CategoryController.php @@ -0,0 +1,90 @@ +only(['search', 'direction_id', 'is_active']); + $data = $this->listCategoriesAction->run($filters); + + return Inertia::render('Dashboard/AdditionalEducations/Categories/Index', $data); + } + + public function create(): \Inertia\Response + { + $data = $this->listCategoriesAction->run([]); + + return Inertia::render('Dashboard/AdditionalEducations/Categories/Create', [ + 'directions' => $data['directions'], + ]); + } + + public function store(StoreCategoryRequest $request): RedirectResponse + { + try { + $validated = $request->validated(); + $this->createCategoryAction->run($validated); + + return redirect()->route('dashboard.additional-educations.categories.index') + ->with('success', 'Категория дополнительного образования успешно создана!'); + } catch (\Exception $e) { + return back()->withInput()->with('error', 'Ошибка при создании категории: ' . $e->getMessage()); + } + } + + public function edit(AdditionalEducationCategory $category): \Inertia\Response + { + $category->load(['direction']); + $data = $this->listCategoriesAction->run([]); + + return Inertia::render('Dashboard/AdditionalEducations/Categories/Edit', [ + 'category' => $category, + 'directions' => $data['directions'], + ]); + } + + public function update(UpdateCategoryRequest $request, AdditionalEducationCategory $category): RedirectResponse + { + try { + $validated = $request->validated(); + $this->updateCategoryAction->run($category, $validated); + + return redirect()->route('dashboard.additional-educations.categories.index') + ->with('success', 'Категория успешно обновлена!'); + } catch (\Exception $e) { + return back()->withInput()->with('error', 'Ошибка при обновлении категории: ' . $e->getMessage()); + } + } + + public function destroy(AdditionalEducationCategory $category): RedirectResponse + { + try { + $this->deleteCategoryAction->run($category); + + return redirect()->route('dashboard.additional-educations.categories.index') + ->with('success', 'Категория успешно удалена!'); + } catch (\Exception $e) { + return back()->with('error', 'Ошибка при удалении категории: ' . $e->getMessage()); + } + } +} diff --git a/app/Containers/Dashboard/UI/WEB/Controllers/AdditionalEducations/DirectionController.php b/app/Containers/Dashboard/UI/WEB/Controllers/AdditionalEducations/DirectionController.php new file mode 100644 index 0000000..4a309e0 --- /dev/null +++ b/app/Containers/Dashboard/UI/WEB/Controllers/AdditionalEducations/DirectionController.php @@ -0,0 +1,83 @@ +only(['search', 'is_active']); + $data = $this->listDirectionsAction->run($filters); + + return Inertia::render('Dashboard/AdditionalEducations/Directions/Index', $data); + } + + public function create(): \Inertia\Response + { + return Inertia::render('Dashboard/AdditionalEducations/Directions/Create'); + } + + public function store(StoreDirectionRequest $request): RedirectResponse + { + try { + $validated = $request->validated(); + $this->createDirectionAction->run($validated); + + return redirect()->route('dashboard.additional-educations.directions.index') + ->with('success', 'Направление дополнительного образования успешно создано!'); + } catch (\Exception $e) { + return back()->withInput()->with('error', 'Ошибка при создании направления: ' . $e->getMessage()); + } + } + + public function edit(DirectionAdditionalEducation $direction): \Inertia\Response + { + return Inertia::render('Dashboard/AdditionalEducations/Directions/Edit', [ + 'direction' => $direction, + ]); + } + + public function update(UpdateDirectionRequest $request, DirectionAdditionalEducation $direction): RedirectResponse + { + try { + $validated = $request->validated(); + $this->updateDirectionAction->run($direction, $validated); + + return redirect()->route('dashboard.additional-educations.directions.index') + ->with('success', 'Направление успешно обновлено!'); + } catch (\Exception $e) { + return back()->withInput()->with('error', 'Ошибка при обновлении направления: ' . $e->getMessage()); + } + } + + public function destroy(DirectionAdditionalEducation $direction): RedirectResponse + { + try { + $this->deleteDirectionAction->run($direction); + + return redirect()->route('dashboard.additional-educations.directions.index') + ->with('success', 'Направление успешно удалено!'); + } catch (\Exception $e) { + return back()->with('error', 'Ошибка при удалении направления: ' . $e->getMessage()); + } + } +} diff --git a/app/Containers/Dashboard/UI/WEB/Controllers/AdmissionCampaignController.php b/app/Containers/Dashboard/UI/WEB/Controllers/AdmissionCampaignController.php new file mode 100644 index 0000000..5b859f4 --- /dev/null +++ b/app/Containers/Dashboard/UI/WEB/Controllers/AdmissionCampaignController.php @@ -0,0 +1,110 @@ +only(['search', 'status', 'academic_year']); + $data = $this->listAdmissionCampaignsAction->run($filters); + + return Inertia::render('Dashboard/AdmissionCampaigns/Index', $data); + } + + /** + * Показывает форму создания кампании + */ + public function create(): \Inertia\Response + { + $data = $this->listAdmissionCampaignsAction->run([]); + + return Inertia::render('Dashboard/AdmissionCampaigns/Create', [ + 'statuses' => $data['statuses'], + 'academicYears' => $data['academicYears'], + ]); + } + + /** + * Создает новую приемную кампанию + */ + public function store(StoreAdmissionCampaignRequest $request): RedirectResponse + { + try { + $validated = $request->validated(); + $this->createAdmissionCampaignAction->run($validated); + + return redirect()->route('dashboard.admission-campaigns.index') + ->with('success', 'Приемная кампания успешно создана!'); + } catch (\Exception $e) { + return back()->withInput()->with('error', 'Ошибка при создании кампании: ' . $e->getMessage()); + } + } + + /** + * Показывает форму редактирования кампании + */ + public function edit(AdmissionCampaign $admissionCampaign): \Inertia\Response + { + $data = $this->listAdmissionCampaignsAction->run([]); + + return Inertia::render('Dashboard/AdmissionCampaigns/Edit', [ + 'campaign' => $admissionCampaign, + 'statuses' => $data['statuses'], + 'academicYears' => $data['academicYears'], + ]); + } + + /** + * Обновляет существующую приемную кампанию + */ + public function update(UpdateAdmissionCampaignRequest $request, AdmissionCampaign $admissionCampaign): RedirectResponse + { + try { + $validated = $request->validated(); + $this->updateAdmissionCampaignAction->run($admissionCampaign, $validated); + + return redirect()->route('dashboard.admission-campaigns.index') + ->with('success', 'Приемная кампания успешно обновлена!'); + } catch (\Exception $e) { + return back()->withInput()->with('error', 'Ошибка при обновлении кампании: ' . $e->getMessage()); + } + } + + /** + * Удаляет приемную кампанию + */ + public function destroy(AdmissionCampaign $admissionCampaign): RedirectResponse + { + try { + $this->deleteAdmissionCampaignAction->run($admissionCampaign); + + return redirect()->route('dashboard.admission-campaigns.index') + ->with('success', 'Приемная кампания успешно удалена!'); + } catch (\Exception $e) { + return back()->with('error', 'Ошибка при удалении кампании: ' . $e->getMessage()); + } + } +} diff --git a/app/Containers/Dashboard/UI/WEB/Controllers/AdmissionPlanController.php b/app/Containers/Dashboard/UI/WEB/Controllers/AdmissionPlanController.php new file mode 100644 index 0000000..0068dfa --- /dev/null +++ b/app/Containers/Dashboard/UI/WEB/Controllers/AdmissionPlanController.php @@ -0,0 +1,92 @@ +only(['admission_campaigns_id', 'educational_programs_id']); + $data = $this->listAdmissionPlansAction->run($filters); + + return Inertia::render('Dashboard/AdmissionPlans/Index', $data); + } + + public function create(): \Inertia\Response + { + $data = $this->listAdmissionPlansAction->run([]); + + return Inertia::render('Dashboard/AdmissionPlans/Create', [ + 'admissionCampaigns' => $data['admissionCampaigns'], + 'educationalPrograms' => $data['educationalPrograms'], + ]); + } + + public function store(StoreAdmissionPlanRequest $request): RedirectResponse + { + try { + $validated = $request->validated(); + $this->createAdmissionPlanAction->run($validated); + + return redirect()->route('dashboard.admission-plans.index') + ->with('success', 'План приема успешно создан!'); + } catch (\Exception $e) { + return back()->withInput()->with('error', 'Ошибка при создании плана: ' . $e->getMessage()); + } + } + + public function edit(AdmissionPlan $admissionPlan): \Inertia\Response + { + $admissionPlan->load(['educationalProgram', 'admissionCampaign']); + $data = $this->listAdmissionPlansAction->run([]); + + return Inertia::render('Dashboard/AdmissionPlans/Edit', [ + 'plan' => $admissionPlan, + 'admissionCampaigns' => $data['admissionCampaigns'], + 'educationalPrograms' => $data['educationalPrograms'], + ]); + } + + public function update(UpdateAdmissionPlanRequest $request, AdmissionPlan $admissionPlan): RedirectResponse + { + try { + $validated = $request->validated(); + $this->updateAdmissionPlanAction->run($admissionPlan, $validated); + + return redirect()->route('dashboard.admission-plans.index') + ->with('success', 'План приема успешно обновлен!'); + } catch (\Exception $e) { + return back()->withInput()->with('error', 'Ошибка при обновлении плана: ' . $e->getMessage()); + } + } + + public function destroy(AdmissionPlan $admissionPlan): RedirectResponse + { + try { + $this->deleteAdmissionPlanAction->run($admissionPlan); + + return redirect()->route('dashboard.admission-plans.index') + ->with('success', 'План приема успешно удален!'); + } catch (\Exception $e) { + return back()->with('error', 'Ошибка при удалении плана: ' . $e->getMessage()); + } + } +} diff --git a/app/Containers/Dashboard/UI/WEB/Controllers/CategoryController.php b/app/Containers/Dashboard/UI/WEB/Controllers/CategoryController.php new file mode 100644 index 0000000..4108cbf --- /dev/null +++ b/app/Containers/Dashboard/UI/WEB/Controllers/CategoryController.php @@ -0,0 +1,91 @@ +only(['search', 'is_active']); + + $data = $this->listCategoriesAction->run($filters); + + return Inertia::render('Dashboard/Categories/Index', $data); + } + + public function create(): \Inertia\Response + { + return Inertia::render('Dashboard/Categories/Create'); + } + + public function store(StoreNewsCategoryRequest $request): RedirectResponse + { + try { + $validated = $request->validated(); + + $this->createCategoryAction->run($validated); + + return redirect()->route('dashboard.categories.index') + ->with('success', 'Категория успешно создана!'); + } catch (\Exception $e) { + return back() + ->withInput() + ->with('error', 'Ошибка при создании категории: ' . $e->getMessage()); + } + } + + public function edit(Category $category): \Inertia\Response + { + return Inertia::render('Dashboard/Categories/Edit', [ + 'category' => $category, + ]); + } + + public function update(UpdateNewsCategoryRequest $request, Category $category): RedirectResponse + { + try { + $validated = $request->validated(); + + $this->updateCategoryAction->run($category, $validated); + + return redirect()->route('dashboard.categories.index') + ->with('success', 'Категория успешно обновлена!'); + } catch (\Exception $e) { + return back() + ->withInput() + ->with('error', 'Ошибка при обновлении категории: ' . $e->getMessage()); + } + } + + public function destroy(Category $category): RedirectResponse + { + try { + $this->deleteCategoryAction->run($category); + + return redirect()->route('dashboard.categories.index') + ->with('success', 'Категория успешно удалена!'); + } catch (\Exception $e) { + return back() + ->with('error', 'Ошибка при удалении категории: ' . $e->getMessage()); + } + } +} diff --git a/app/Containers/Dashboard/UI/WEB/Controllers/ContactWidgetController.php b/app/Containers/Dashboard/UI/WEB/Controllers/ContactWidgetController.php new file mode 100644 index 0000000..6f9d2d7 --- /dev/null +++ b/app/Containers/Dashboard/UI/WEB/Controllers/ContactWidgetController.php @@ -0,0 +1,109 @@ +only(['search', 'is_active']); + + $data = $this->listContactWidgetsAction->run($filters); + + return Inertia::render('Dashboard/ContactWidgets/Index', $data); + } + + /** + * Показывает форму создания виджета + */ + public function create(): \Inertia\Response + { + return Inertia::render('Dashboard/ContactWidgets/Create'); + } + + /** + * Создает новый контактный виджет + */ + public function store(StoreContactWidgetRequest $request): RedirectResponse + { + try { + $validated = $request->validated(); + + $this->createContactWidgetAction->run($validated); + + return redirect()->route('dashboard.contact-widgets.index') + ->with('success', 'Контактный виджет успешно создан!'); + } catch (\Exception $e) { + return back() + ->withInput() + ->with('error', 'Ошибка при создании виджета: ' . $e->getMessage()); + } + } + + /** + * Показывает форму редактирования виджета + */ + public function edit(ContactWidget $contactWidget): \Inertia\Response + { + return Inertia::render('Dashboard/ContactWidgets/Edit', [ + 'widget' => $contactWidget, + ]); + } + + /** + * Обновляет контактный виджет + */ + public function update(UpdateContactWidgetRequest $request, ContactWidget $contactWidget): RedirectResponse + { + try { + $validated = $request->validated(); + + $this->updateContactWidgetAction->run($contactWidget, $validated); + + return redirect()->route('dashboard.contact-widgets.index') + ->with('success', 'Контактный виджет успешно обновлён!'); + } catch (\Exception $e) { + return back() + ->withInput() + ->with('error', 'Ошибка при обновлении виджета: ' . $e->getMessage()); + } + } + + /** + * Удаляет контактный виджет + */ + public function destroy(ContactWidget $contactWidget): RedirectResponse + { + try { + $this->deleteContactWidgetAction->run($contactWidget); + + return redirect()->route('dashboard.contact-widgets.index') + ->with('success', 'Контактный виджет успешно удалён!'); + } catch (\Exception $e) { + return back() + ->with('error', 'Ошибка при удалении виджета: ' . $e->getMessage()); + } + } +} diff --git a/app/Containers/Dashboard/UI/WEB/Controllers/CreateSliderController.php b/app/Containers/Dashboard/UI/WEB/Controllers/CreateSliderController.php new file mode 100644 index 0000000..31679b8 --- /dev/null +++ b/app/Containers/Dashboard/UI/WEB/Controllers/CreateSliderController.php @@ -0,0 +1,14 @@ +only(['search', 'status']); + + $data = $this->listCustomFormsAction->run($filters); + + return Inertia::render('Dashboard/CustomForms/Index', $data); + } + + /** + * Показывает форму создания формы + */ + public function create(): \Inertia\Response + { + return Inertia::render('Dashboard/CustomForms/Create'); + } + + /** + * Создает новую пользовательскую форму + */ + public function store(StoreCustomFormRequest $request): RedirectResponse + { + try { + $validated = $request->validated(); + + $this->createCustomFormAction->run($validated); + + return redirect()->route('dashboard.custom-forms.index') + ->with('success', 'Пользовательская форма успешно создана!'); + } catch (\Exception $e) { + return back() + ->withInput() + ->with('error', 'Ошибка при создании формы: ' . $e->getMessage()); + } + } + + /** + * Показывает форму редактирования формы + */ + public function edit(CustomForm $customForm): \Inertia\Response + { + return Inertia::render('Dashboard/CustomForms/Edit', [ + 'form' => $customForm, + ]); + } + + /** + * Обновляет пользовательскую форму + */ + public function update(UpdateCustomFormRequest $request, CustomForm $customForm): RedirectResponse + { + try { + $validated = $request->validated(); + + $this->updateCustomFormAction->run($customForm, $validated); + + return redirect()->route('dashboard.custom-forms.index') + ->with('success', 'Пользовательская форма успешно обновлена!'); + } catch (\Exception $e) { + return back() + ->withInput() + ->with('error', 'Ошибка при обновлении формы: ' . $e->getMessage()); + } + } + + /** + * Удаляет пользовательскую форму + */ + public function destroy(CustomForm $customForm): RedirectResponse + { + try { + $this->deleteCustomFormAction->run($customForm); + + return redirect()->route('dashboard.custom-forms.index') + ->with('success', 'Пользовательская форма успешно удалена!'); + } catch (\Exception $e) { + return back() + ->with('error', 'Ошибка при удалении формы: ' . $e->getMessage()); + } + } + + /** + * Показывает ответы на форму + */ + public function responses(CustomForm $customForm, \Illuminate\Http\Request $request): \Inertia\Response + { + $filters = $request->only(['search', 'checked']); + + $data = $this->listFormResponsesAction->run($customForm, $filters); + + return Inertia::render('Dashboard/CustomForms/Responses', $data); + } + + /** + * Переключает статус просмотра ответа + */ + public function toggleResponseChecked(CustomForm $customForm, \App\Containers\Widget\Models\CustomFormResponse $response): RedirectResponse + { + $response->update(['checked' => !$response->checked]); + + return back(); + } + + /** + * Удаляет ответ на форму + */ + public function destroyResponse(CustomForm $customForm, \App\Containers\Widget\Models\CustomFormResponse $response): RedirectResponse + { + try { + $this->deleteFormResponseAction->run($response); + + return back()->with('success', 'Ответ успешно удал!'); + } catch (\Exception $e) { + return back()->with('error', 'Ошибка при удалении ответа: ' . $e->getMessage()); + } + } +} diff --git a/app/Containers/Dashboard/UI/WEB/Controllers/DepartmentController.php b/app/Containers/Dashboard/UI/WEB/Controllers/DepartmentController.php new file mode 100644 index 0000000..b899a83 --- /dev/null +++ b/app/Containers/Dashboard/UI/WEB/Controllers/DepartmentController.php @@ -0,0 +1,160 @@ +only(['search', 'faculty_id', 'is_active']); + + $data = $this->listDepartmentsAction->run($filters); + + // Добавляем список факультетов для фильтра + $data['faculties'] = Faculty::query() + ->orderBy('title') + ->get(['id', 'title']); + + return Inertia::render('Dashboard/Departments/Index', $data); + } + + /** + * Показывает форму создания кафедры + */ + public function create(): \Inertia\Response + { + return Inertia::render('Dashboard/Departments/Create', [ + 'faculties' => Faculty::query() + ->orderBy('title') + ->get(['id', 'title']), + ]); + } + + /** + * Создает новую кафедру + */ + public function store(StoreDepartmentRequest $request): RedirectResponse + { + try { + $validated = $request->validated(); + + $this->createDepartmentAction->run($validated); + + return redirect()->route('dashboard.departments.index') + ->with('success', 'Кафедра успешно создана!'); + } catch (\Exception $e) { + return back() + ->withInput() + ->with('error', 'Ошибка при создании кафедры: ' . $e->getMessage()); + } + } + + /** + * Показывает форму редактирования кафедры + */ + public function edit(Department $department): \Inertia\Response + { + $department->load(['faculty', 'seo']); + + // Получаем список работников + $workersData = $this->listDepartmentWorkersAction->run($department, []); + $teachersData = $this->listDepartmentTeachersAction->run($department, []); + $programsData = $this->listDepartmentProgramsAction->run($department, []); + + // Получаем список доступных пользователей + $availableWorkers = User::whereHas('userDetail') + ->whereDoesntHave('departments_work', fn($q) => $q->where('departments.id', $department->id)) + ->orderBy('name') + ->get(['id', 'name']); + + $availableTeachers = User::whereHas('userDetail') + ->whereDoesntHave('departments_teach', fn($q) => $q->where('departments.id', $department->id)) + ->orderBy('name') + ->get(['id', 'name']); + + // Получаем список доступных программ (только опубликованные) + $availablePrograms = EducationalProgram::where('status', 'published') + ->whereDoesntHave('departments', fn($q) => $q->where('departments.id', $department->id)) + ->orderBy('name') + ->get(['id', 'name', 'status']); + + return Inertia::render('Dashboard/Departments/Edit', [ + 'department' => $department, + 'workers' => $workersData['workers'], + 'availableWorkers' => $availableWorkers, + 'teachers' => $teachersData['teachers'], + 'availableTeachers' => $availableTeachers, + 'programs' => $programsData['programs'], + 'availablePrograms' => $availablePrograms, + 'faculties' => Faculty::query() + ->orderBy('title') + ->get(['id', 'title']), + ]); + } + + /** + * Обновляет существующую кафедру + */ + public function update(UpdateDepartmentRequest $request, Department $department): RedirectResponse + { + try { + $validated = $request->validated(); + + $this->updateDepartmentAction->run($department, $validated); + + return redirect()->route('dashboard.departments.index') + ->with('success', 'Кафедра успешно обновлена!'); + } catch (\Exception $e) { + return back() + ->withInput() + ->with('error', 'Ошибка при обновлении кафедры: ' . $e->getMessage()); + } + } + + /** + * Удаляет кафедру + */ + public function destroy(Department $department): RedirectResponse + { + try { + $this->deleteDepartmentAction->run($department); + + return redirect()->route('dashboard.departments.index') + ->with('success', 'Кафедра успешно удалена!'); + } catch (\Exception $e) { + return back() + ->with('error', 'Ошибка при удалении кафедры: ' . $e->getMessage()); + } + } +} diff --git a/app/Containers/Dashboard/UI/WEB/Controllers/DepartmentProgramController.php b/app/Containers/Dashboard/UI/WEB/Controllers/DepartmentProgramController.php new file mode 100644 index 0000000..787786c --- /dev/null +++ b/app/Containers/Dashboard/UI/WEB/Controllers/DepartmentProgramController.php @@ -0,0 +1,81 @@ +only(['search', 'status']); + $data = $this->listDepartmentProgramsAction->run($department, $filters); + + // Получаем список доступных программ для прикрепления (только опубликованные) + $availablePrograms = EducationalProgram::where('status', 'published') + ->whereDoesntHave('departments', fn($q) => $q->where('departments.id', $department->id)) + ->orderBy('name') + ->get(['id', 'name', 'status']); + + return Inertia::render('Dashboard/Departments/Programs/Index', [ + 'department' => $department, + 'programs' => $data['programs'], + 'availablePrograms' => $availablePrograms, + 'filters' => $data['filters'], + ]); + } + + /** + * Прикрепляет программу к кафедре + */ + public function attach(Department $department, AttachDepartmentProgramRequest $request): RedirectResponse + { + try { + $validated = $request->validated(); + $program = EducationalProgram::findOrFail($validated['program_id']); + + $this->attachDepartmentProgramAction->run($department, $program); + + return redirect()->route('dashboard.departments.edit', $department->id) + ->with('success', 'Программа успешно добавлена!'); + } catch (\Exception $e) { + return back() + ->withInput() + ->with('error', 'Ошибка при добавлении программы: ' . $e->getMessage()); + } + } + + /** + * Открепляет программу от кафедры + */ + public function detach(Department $department, EducationalProgram $program): RedirectResponse + { + try { + $this->detachDepartmentProgramAction->run($department, $program); + + return redirect()->route('dashboard.departments.edit', $department->id) + ->with('success', 'Программа удалена из кафедры!'); + } catch (\Exception $e) { + return back() + ->with('error', 'Ошибка при удалении программы: ' . $e->getMessage()); + } + } +} diff --git a/app/Containers/Dashboard/UI/WEB/Controllers/DepartmentTeacherController.php b/app/Containers/Dashboard/UI/WEB/Controllers/DepartmentTeacherController.php new file mode 100644 index 0000000..2af3375 --- /dev/null +++ b/app/Containers/Dashboard/UI/WEB/Controllers/DepartmentTeacherController.php @@ -0,0 +1,102 @@ +only(['search', 'position']); + $data = $this->listDepartmentTeachersAction->run($department, $filters); + + // Получаем список доступных пользователей для прикрепления + $availableUsers = User::whereHas('userDetail') + ->whereDoesntHave('departments_teach', fn($q) => $q->where('departments.id', $department->id)) + ->orderBy('name') + ->get(['id', 'name']); + + return Inertia::render('Dashboard/Departments/Teachers/Index', [ + 'department' => $department, + 'teachers' => $data['teachers'], + 'availableUsers' => $availableUsers, + 'filters' => $data['filters'], + ]); + } + + /** + * Прикрепляет преподавателя к кафедре + */ + public function attach(Department $department, AttachDepartmentTeacherRequest $request): RedirectResponse + { + try { + $validated = $request->validated(); + $user = User::findOrFail($validated['user_id']); + + $this->attachDepartmentTeacherAction->run($department, $user, $validated); + + return redirect()->route('dashboard.departments.edit', $department->id) + ->with('success', 'Преподаватель успешно добавлен!'); + } catch (\Exception $e) { + return back() + ->withInput() + ->with('error', 'Ошибка при добавлении преподавателя: ' . $e->getMessage()); + } + } + + /** + * Обновляет данные преподавателя + */ + public function update(Department $department, User $teacher, AttachDepartmentTeacherRequest $request): RedirectResponse + { + try { + $validated = $request->validated(); + + $this->updateDepartmentTeacherAction->run($department, $teacher, $validated); + + return redirect()->route('dashboard.departments.edit', $department->id) + ->with('success', 'Данные преподавателя обновлены!'); + } catch (\Exception $e) { + return back() + ->withInput() + ->with('error', 'Ошибка при обновлении преподавателя: ' . $e->getMessage()); + } + } + + /** + * Открепляет преподавателя от кафедры + */ + public function detach(Department $department, User $teacher): RedirectResponse + { + try { + $this->detachDepartmentTeacherAction->run($department, $teacher); + + return redirect()->route('dashboard.departments.edit', $department->id) + ->with('success', 'Преподаватель удален из кафедры!'); + } catch (\Exception $e) { + return back() + ->with('error', 'Ошибка при удалении преподавателя: ' . $e->getMessage()); + } + } +} diff --git a/app/Containers/Dashboard/UI/WEB/Controllers/DepartmentWorkerController.php b/app/Containers/Dashboard/UI/WEB/Controllers/DepartmentWorkerController.php new file mode 100644 index 0000000..b2dae8d --- /dev/null +++ b/app/Containers/Dashboard/UI/WEB/Controllers/DepartmentWorkerController.php @@ -0,0 +1,102 @@ +only(['search', 'position']); + $data = $this->listDepartmentWorkersAction->run($department, $filters); + + // Получаем список доступных пользователей для прикрепления + $availableUsers = User::whereHas('userDetail') + ->whereDoesntHave('departments_work', fn($q) => $q->where('departments.id', $department->id)) + ->orderBy('name') + ->get(['id', 'name']); + + return Inertia::render('Dashboard/Departments/Workers/Index', [ + 'department' => $department, + 'workers' => $data['workers'], + 'availableUsers' => $availableUsers, + 'filters' => $data['filters'], + ]); + } + + /** + * Прикрепляет сотрудника к кафедре + */ + public function attach(Department $department, AttachDepartmentWorkerRequest $request): RedirectResponse + { + try { + $validated = $request->validated(); + $user = User::findOrFail($validated['user_id']); + + $this->attachDepartmentWorkerAction->run($department, $user, $validated); + + return redirect()->route('dashboard.departments.edit', $department->id) + ->with('success', 'Сотрудник успешно добавлен!'); + } catch (\Exception $e) { + return back() + ->withInput() + ->with('error', 'Ошибка при добавлении сотрудника: ' . $e->getMessage()); + } + } + + /** + * Обновляет данные сотрудника + */ + public function update(Department $department, User $worker, AttachDepartmentWorkerRequest $request): RedirectResponse + { + try { + $validated = $request->validated(); + + $this->updateDepartmentWorkerAction->run($department, $worker, $validated); + + return redirect()->route('dashboard.departments.edit', $department->id) + ->with('success', 'Данные сотрудника обновлены!'); + } catch (\Exception $e) { + return back() + ->withInput() + ->with('error', 'Ошибка при обновлении сотрудника: ' . $e->getMessage()); + } + } + + /** + * Открепляет сотрудника от кафедры + */ + public function detach(Department $department, User $worker): RedirectResponse + { + try { + $this->detachDepartmentWorkerAction->run($department, $worker); + + return redirect()->route('dashboard.departments.edit', $department->id) + ->with('success', 'Сотрудник удален из кафедры!'); + } catch (\Exception $e) { + return back() + ->with('error', 'Ошибка при удалении сотрудника: ' . $e->getMessage()); + } + } +} diff --git a/app/Containers/Dashboard/UI/WEB/Controllers/DestroySlideController.php b/app/Containers/Dashboard/UI/WEB/Controllers/DestroySlideController.php new file mode 100644 index 0000000..256d8c0 --- /dev/null +++ b/app/Containers/Dashboard/UI/WEB/Controllers/DestroySlideController.php @@ -0,0 +1,24 @@ +deleteSlideAction->run($slide); + + return response()->json([ + 'success' => true, + ]); + } +} diff --git a/app/Containers/Dashboard/UI/WEB/Controllers/DestroySliderController.php b/app/Containers/Dashboard/UI/WEB/Controllers/DestroySliderController.php new file mode 100644 index 0000000..ec66c43 --- /dev/null +++ b/app/Containers/Dashboard/UI/WEB/Controllers/DestroySliderController.php @@ -0,0 +1,23 @@ +deleteSliderAction->run($slider); + + return redirect()->route('dashboard.sliders.index') + ->with('success', 'Слайдер удален'); + } +} diff --git a/app/Containers/Dashboard/UI/WEB/Controllers/DirectionStudyController.php b/app/Containers/Dashboard/UI/WEB/Controllers/DirectionStudyController.php new file mode 100644 index 0000000..bc844e3 --- /dev/null +++ b/app/Containers/Dashboard/UI/WEB/Controllers/DirectionStudyController.php @@ -0,0 +1,90 @@ +only(['search', 'lvl_edu']); + $data = $this->listDirectionStudiesAction->run($filters); + + return Inertia::render('Dashboard/DirectionStudies/Index', $data); + } + + public function create(): \Inertia\Response + { + $data = $this->listDirectionStudiesAction->run([]); + + return Inertia::render('Dashboard/DirectionStudies/Create', [ + 'educationLevels' => $data['educationLevels'], + ]); + } + + public function store(StoreDirectionStudyRequest $request): RedirectResponse + { + try { + $validated = $request->validated(); + $this->createDirectionStudyAction->run($validated); + + return redirect()->route('dashboard.direction-studies.index') + ->with('success', 'Направление подготовки успешно создано!'); + } catch (\Exception $e) { + return back()->withInput()->with('error', 'Ошибка при создании направления: ' . $e->getMessage()); + } + } + + public function edit(DirectionStudy $directionStudy): \Inertia\Response + { + $data = $this->listDirectionStudiesAction->run([]); + + return Inertia::render('Dashboard/DirectionStudies/Edit', [ + 'direction' => $directionStudy, + 'educationLevels' => $data['educationLevels'], + ]); + } + + public function update(UpdateDirectionStudyRequest $request, DirectionStudy $directionStudy): RedirectResponse + { + try { + $validated = $request->validated(); + $this->updateDirectionStudyAction->run($directionStudy, $validated); + + return redirect()->route('dashboard.direction-studies.index') + ->with('success', 'Направление подготовки успешно обновлено!'); + } catch (\Exception $e) { + return back()->withInput()->with('error', 'Ошибка при обновлении направления: ' . $e->getMessage()); + } + } + + public function destroy(DirectionStudy $directionStudy): RedirectResponse + { + try { + $this->deleteDirectionStudyAction->run($directionStudy); + + return redirect()->route('dashboard.direction-studies.index') + ->with('success', 'Направление подготовки успешно удалено!'); + } catch (\Exception $e) { + return back()->with('error', 'Ошибка при удалении направления: ' . $e->getMessage()); + } + } +} diff --git a/app/Containers/Dashboard/UI/WEB/Controllers/DivisionController.php b/app/Containers/Dashboard/UI/WEB/Controllers/DivisionController.php new file mode 100644 index 0000000..437c26b --- /dev/null +++ b/app/Containers/Dashboard/UI/WEB/Controllers/DivisionController.php @@ -0,0 +1,125 @@ +only(['search', 'is_active']); + + $data = $this->listDivisionsAction->run($filters); + + return Inertia::render('Dashboard/Divisions/Index', $data); + } + + /** + * Показывает форму создания подразделения + */ + public function create(): \Inertia\Response + { + return Inertia::render('Dashboard/Divisions/Create'); + } + + /** + * Создает новое подразделение + */ + public function store(StoreDivisionRequest $request): RedirectResponse + { + try { + $validated = $request->validated(); + + $this->createDivisionAction->run($validated); + + return redirect()->route('dashboard.divisions.index') + ->with('success', 'Подразделение успешно создано!'); + } catch (\Exception $e) { + return back() + ->withInput() + ->with('error', 'Ошибка при создании подразделения: ' . $e->getMessage()); + } + } + + /** + * Показывает форму редактирования подразделения + */ + public function edit(Division $division): \Inertia\Response + { + $division->load(['seo']); + + // Получаем список работников + $workersData = $this->listDivisionWorkersAction->run($division, []); + + // Получаем список доступных пользователей + $availableWorkers = User::whereHas('userDetail') + ->whereDoesntHave('divisions', fn($q) => $q->where('divisions.id', $division->id)) + ->orderBy('name') + ->get(['id', 'name']); + + return Inertia::render('Dashboard/Divisions/Edit', [ + 'division' => $division, + 'workers' => $workersData['workers'], + 'availableWorkers' => $availableWorkers, + ]); + } + + /** + * Обновляет существующее подразделение + */ + public function update(UpdateDivisionRequest $request, Division $division): RedirectResponse + { + try { + $validated = $request->validated(); + + $this->updateDivisionAction->run($division, $validated); + + return redirect()->route('dashboard.divisions.index') + ->with('success', 'Подразделение успешно обновлено!'); + } catch (\Exception $e) { + return back() + ->withInput() + ->with('error', 'Ошибка при обновлении подразделения: ' . $e->getMessage()); + } + } + + /** + * Удаляет подразделение + */ + public function destroy(Division $division): RedirectResponse + { + try { + $this->deleteDivisionAction->run($division); + + return redirect()->route('dashboard.divisions.index') + ->with('success', 'Подразделение успешно удалено!'); + } catch (\Exception $e) { + return back() + ->with('error', 'Ошибка при удалении подразделения: ' . $e->getMessage()); + } + } +} diff --git a/app/Containers/Dashboard/UI/WEB/Controllers/DivisionWorkerController.php b/app/Containers/Dashboard/UI/WEB/Controllers/DivisionWorkerController.php new file mode 100644 index 0000000..efa9263 --- /dev/null +++ b/app/Containers/Dashboard/UI/WEB/Controllers/DivisionWorkerController.php @@ -0,0 +1,102 @@ +only(['search', 'position']); + $data = $this->listDivisionWorkersAction->run($division, $filters); + + // Получаем список доступных пользователей для прикрепления + $availableUsers = User::whereHas('userDetail') + ->whereDoesntHave('divisions', fn($q) => $q->where('divisions.id', $division->id)) + ->orderBy('name') + ->get(['id', 'name']); + + return Inertia::render('Dashboard/Divisions/Workers/Index', [ + 'division' => $division, + 'workers' => $data['workers'], + 'availableUsers' => $availableUsers, + 'filters' => $data['filters'], + ]); + } + + /** + * Прикрепляет сотрудника к подразделению + */ + public function attach(Division $division, AttachDivisionWorkerRequest $request): RedirectResponse + { + try { + $validated = $request->validated(); + $user = User::findOrFail($validated['user_id']); + + $this->attachDivisionWorkerAction->run($division, $user, $validated); + + return redirect()->route('dashboard.divisions.edit', $division->id) + ->with('success', 'Сотрудник успешно добавлен!'); + } catch (\Exception $e) { + return back() + ->withInput() + ->with('error', 'Ошибка при добавлении сотрудника: ' . $e->getMessage()); + } + } + + /** + * Обновляет данные сотрудника + */ + public function update(Division $division, User $worker, AttachDivisionWorkerRequest $request): RedirectResponse + { + try { + $validated = $request->validated(); + + $this->updateDivisionWorkerAction->run($division, $worker, $validated); + + return redirect()->route('dashboard.divisions.edit', $division->id) + ->with('success', 'Данные сотрудника обновлены!'); + } catch (\Exception $e) { + return back() + ->withInput() + ->with('error', 'Ошибка при обновлении сотрудника: ' . $e->getMessage()); + } + } + + /** + * Открепляет сотрудника от подразделения + */ + public function detach(Division $division, User $worker): RedirectResponse + { + try { + $this->detachDivisionWorkerAction->run($division, $worker); + + return redirect()->route('dashboard.divisions.edit', $division->id) + ->with('success', 'Сотрудник удален из подразделения!'); + } catch (\Exception $e) { + return back() + ->with('error', 'Ошибка при удалении сотрудника: ' . $e->getMessage()); + } + } +} diff --git a/app/Containers/Dashboard/UI/WEB/Controllers/EditSliderController.php b/app/Containers/Dashboard/UI/WEB/Controllers/EditSliderController.php new file mode 100644 index 0000000..58b95c0 --- /dev/null +++ b/app/Containers/Dashboard/UI/WEB/Controllers/EditSliderController.php @@ -0,0 +1,32 @@ +load('slides'); + $slides = $this->listSlidesAction->run($slider); + + $posts = Post::where('status', 'published') + ->orderBy('created_at', 'desc') + ->get(['id', 'title', 'slug']); + + return Inertia::render('Dashboard/Sliders/Edit', [ + 'slider' => $slider, + 'slides' => $slides, + 'posts' => $posts, + ]); + } +} diff --git a/app/Containers/Dashboard/UI/WEB/Controllers/EducationalGroupController.php b/app/Containers/Dashboard/UI/WEB/Controllers/EducationalGroupController.php new file mode 100644 index 0000000..5d3664e --- /dev/null +++ b/app/Containers/Dashboard/UI/WEB/Controllers/EducationalGroupController.php @@ -0,0 +1,120 @@ +only(['search', 'faculty_id', 'education_form_id']); + + $data = $this->listEducationalGroupsAction->run($filters); + + return Inertia::render('Dashboard/EducationalGroups/Index', $data); + } + + /** + * Показывает форму создания группы + */ + public function create(): \Inertia\Response + { + $data = $this->listEducationalGroupsAction->run([]); + + return Inertia::render('Dashboard/EducationalGroups/Create', [ + 'faculties' => $data['faculties'], + 'educationForms' => $data['educationForms'], + ]); + } + + /** + * Создает новую учебную группу + */ + public function store(StoreEducationalGroupRequest $request): RedirectResponse + { + try { + $validated = $request->validated(); + + $this->createEducationalGroupAction->run($validated); + + return redirect()->route('dashboard.educational-groups.index') + ->with('success', 'Учебная группа успешно создана!'); + } catch (\Exception $e) { + return back() + ->withInput() + ->with('error', 'Ошибка при создании учебной группы: ' . $e->getMessage()); + } + } + + /** + * Показывает форму редактирования группы + */ + public function edit(EducationalGroup $educationalGroup): \Inertia\Response + { + $educationalGroup->load(['faculty']); + + $data = $this->listEducationalGroupsAction->run([]); + + return Inertia::render('Dashboard/EducationalGroups/Edit', [ + 'group' => $educationalGroup, + 'faculties' => $data['faculties'], + 'educationForms' => $data['educationForms'], + ]); + } + + /** + * Обновляет существующую учебную группу + */ + public function update(UpdateEducationalGroupRequest $request, EducationalGroup $educationalGroup): RedirectResponse + { + try { + $validated = $request->validated(); + + $this->updateEducationalGroupAction->run($educationalGroup, $validated); + + return redirect()->route('dashboard.educational-groups.index') + ->with('success', 'Учебная группа успешно обновлена!'); + } catch (\Exception $e) { + return back() + ->withInput() + ->with('error', 'Ошибка при обновлении учебной группы: ' . $e->getMessage()); + } + } + + /** + * Удаляет учебную группу + */ + public function destroy(EducationalGroup $educationalGroup): RedirectResponse + { + try { + $this->deleteEducationalGroupAction->run($educationalGroup); + + return redirect()->route('dashboard.educational-groups.index') + ->with('success', 'Учебная группа успешно удалена!'); + } catch (\Exception $e) { + return back() + ->with('error', 'Ошибка при удалении учебной группы: ' . $e->getMessage()); + } + } +} diff --git a/app/Containers/Dashboard/UI/WEB/Controllers/EducationalProgramController.php b/app/Containers/Dashboard/UI/WEB/Controllers/EducationalProgramController.php new file mode 100644 index 0000000..9af09e6 --- /dev/null +++ b/app/Containers/Dashboard/UI/WEB/Controllers/EducationalProgramController.php @@ -0,0 +1,95 @@ +only(['search', 'lvl_edu', 'status', 'direction_study_id']); + $data = $this->listEducationalProgramsAction->run($filters); + + return Inertia::render('Dashboard/EducationalPrograms/Index', $data); + } + + public function create(): \Inertia\Response + { + $data = $this->listEducationalProgramsAction->run([]); + + return Inertia::render('Dashboard/EducationalPrograms/Create', [ + 'statuses' => $data['statuses'], + 'educationLevels' => $data['educationLevels'], + 'directionStudies' => $data['directionStudies'], + ]); + } + + public function store(StoreEducationalProgramRequest $request): RedirectResponse + { + try { + $validated = $request->validated(); + $this->createEducationalProgramAction->run($validated); + + return redirect()->route('dashboard.educational-programs.index') + ->with('success', 'Образовательная программа успешно создана!'); + } catch (\Exception $e) { + return back()->withInput()->with('error', 'Ошибка при создании программы: ' . $e->getMessage()); + } + } + + public function edit(EducationalProgram $educationalProgram): \Inertia\Response + { + $educationalProgram->load(['directionStudy']); + $data = $this->listEducationalProgramsAction->run([]); + + return Inertia::render('Dashboard/EducationalPrograms/Edit', [ + 'program' => $educationalProgram, + 'statuses' => $data['statuses'], + 'educationLevels' => $data['educationLevels'], + 'directionStudies' => $data['directionStudies'], + ]); + } + + public function update(UpdateEducationalProgramRequest $request, EducationalProgram $educationalProgram): RedirectResponse + { + try { + $validated = $request->validated(); + $this->updateEducationalProgramAction->run($educationalProgram, $validated); + + return redirect()->route('dashboard.educational-programs.index') + ->with('success', 'Образовательная программа успешно обновлена!'); + } catch (\Exception $e) { + return back()->withInput()->with('error', 'Ошибка при обновлении программы: ' . $e->getMessage()); + } + } + + public function destroy(EducationalProgram $educationalProgram): RedirectResponse + { + try { + $this->deleteEducationalProgramAction->run($educationalProgram); + + return redirect()->route('dashboard.educational-programs.index') + ->with('success', 'Образовательная программа успешно удалена!'); + } catch (\Exception $e) { + return back()->with('error', 'Ошибка при удалении программы: ' . $e->getMessage()); + } + } +} diff --git a/app/Containers/Dashboard/UI/WEB/Controllers/FacultyController.php b/app/Containers/Dashboard/UI/WEB/Controllers/FacultyController.php new file mode 100644 index 0000000..09c34df --- /dev/null +++ b/app/Containers/Dashboard/UI/WEB/Controllers/FacultyController.php @@ -0,0 +1,125 @@ +only(['search', 'is_active']); + + $data = $this->listFacultiesAction->run($filters); + + return Inertia::render('Dashboard/Faculties/Index', $data); + } + + /** + * Показывает форму создания факультета + */ + public function create(): \Inertia\Response + { + return Inertia::render('Dashboard/Faculties/Create'); + } + + /** + * Создает новый факультет + */ + public function store(StoreFacultyRequest $request): RedirectResponse + { + try { + $validated = $request->validated(); + + $this->createFacultyAction->run($validated); + + return redirect()->route('dashboard.faculties.index') + ->with('success', 'Факультет успешно создан!'); + } catch (\Exception $e) { + return back() + ->withInput() + ->with('error', 'Ошибка при создании факультета: ' . $e->getMessage()); + } + } + + /** + * Показывает форму редактирования факультета + */ + public function edit(Faculty $faculty): \Inertia\Response + { + $faculty->load(['seo']); + + // Получаем список работников + $workersData = $this->listFacultyWorkersAction->run($faculty, []); + + // Получаем список доступных пользователей + $availableWorkers = User::whereHas('userDetail') + ->whereDoesntHave('faculties', fn($q) => $q->where('faculties.id', $faculty->id)) + ->orderBy('name') + ->get(['id', 'name']); + + return Inertia::render('Dashboard/Faculties/Edit', [ + 'faculty' => $faculty, + 'workers' => $workersData['workers'], + 'availableWorkers' => $availableWorkers, + ]); + } + + /** + * Обновляет существующий факультет + */ + public function update(UpdateFacultyRequest $request, Faculty $faculty): RedirectResponse + { + try { + $validated = $request->validated(); + + $this->updateFacultyAction->run($faculty, $validated); + + return redirect()->route('dashboard.faculties.index') + ->with('success', 'Факультет успешно обновлен!'); + } catch (\Exception $e) { + return back() + ->withInput() + ->with('error', 'Ошибка при обновлении факультета: ' . $e->getMessage()); + } + } + + /** + * Удаляет факультет + */ + public function destroy(Faculty $faculty): RedirectResponse + { + try { + $this->deleteFacultyAction->run($faculty); + + return redirect()->route('dashboard.faculties.index') + ->with('success', 'Факультет успешно удален!'); + } catch (\Exception $e) { + return back() + ->with('error', 'Ошибка при удалении факультета: ' . $e->getMessage()); + } + } +} diff --git a/app/Containers/Dashboard/UI/WEB/Controllers/FacultyWorkerController.php b/app/Containers/Dashboard/UI/WEB/Controllers/FacultyWorkerController.php new file mode 100644 index 0000000..82e391d --- /dev/null +++ b/app/Containers/Dashboard/UI/WEB/Controllers/FacultyWorkerController.php @@ -0,0 +1,102 @@ +only(['search', 'position']); + $data = $this->listFacultyWorkersAction->run($faculty, $filters); + + // Получаем список доступных пользователей для прикрепления + $availableUsers = User::whereHas('userDetail') + ->whereDoesntHave('faculties', fn($q) => $q->where('faculties.id', $faculty->id)) + ->orderBy('name') + ->get(['id', 'name']); + + return Inertia::render('Dashboard/Faculties/Workers/Index', [ + 'faculty' => $faculty, + 'workers' => $data['workers'], + 'availableUsers' => $availableUsers, + 'filters' => $data['filters'], + ]); + } + + /** + * Прикрепляет сотрудника к факультету + */ + public function attach(Faculty $faculty, AttachFacultyWorkerRequest $request): RedirectResponse + { + try { + $validated = $request->validated(); + $user = User::findOrFail($validated['user_id']); + + $this->attachFacultyWorkerAction->run($faculty, $user, $validated); + + return redirect()->route('dashboard.faculties.edit', $faculty->id) + ->with('success', 'Сотрудник успешно добавлен!'); + } catch (\Exception $e) { + return back() + ->withInput() + ->with('error', 'Ошибка при добавлении сотрудника: ' . $e->getMessage()); + } + } + + /** + * Обновляет данные сотрудника + */ + public function update(Faculty $faculty, User $worker, AttachFacultyWorkerRequest $request): RedirectResponse + { + try { + $validated = $request->validated(); + + $this->updateFacultyWorkerAction->run($faculty, $worker, $validated); + + return redirect()->route('dashboard.faculties.edit', $faculty->id) + ->with('success', 'Данные сотрудника обновлены!'); + } catch (\Exception $e) { + return back() + ->withInput() + ->with('error', 'Ошибка при обновлении сотрудника: ' . $e->getMessage()); + } + } + + /** + * Открепляет сотрудника от факультета + */ + public function detach(Faculty $faculty, User $worker): RedirectResponse + { + try { + $this->detachFacultyWorkerAction->run($faculty, $worker); + + return redirect()->route('dashboard.faculties.edit', $faculty->id) + ->with('success', 'Сотрудник удален из факультета!'); + } catch (\Exception $e) { + return back() + ->with('error', 'Ошибка при удалении сотрудника: ' . $e->getMessage()); + } + } +} diff --git a/app/Containers/Dashboard/UI/WEB/Controllers/IndexDashboardController.php b/app/Containers/Dashboard/UI/WEB/Controllers/IndexDashboardController.php index 83d29fe..f5cccdb 100755 --- a/app/Containers/Dashboard/UI/WEB/Controllers/IndexDashboardController.php +++ b/app/Containers/Dashboard/UI/WEB/Controllers/IndexDashboardController.php @@ -2,22 +2,18 @@ namespace App\Containers\Dashboard\UI\WEB\Controllers; -use App\Containers\Dashboard\Tasks\GetAiPreparedPostsTask; +use App\Containers\Dashboard\Actions\LoadDashboardDataAction; use App\Http\Controllers\Controller; use Illuminate\Http\Request; class IndexDashboardController extends Controller { public function __construct( - private readonly GetAiPreparedPostsTask $getAiPreparedPostsTask, + private readonly LoadDashboardDataAction $loadDashboardDataAction, ) {} public function __invoke(Request $request): \Inertia\Response { - $aiPreparedPosts = $this->getAiPreparedPostsTask->run(); - - return inertia()->render('Dashboard/Main', [ - 'aiPreparedPosts' => $aiPreparedPosts, - ]); + return inertia()->render('Dashboard/Main', $this->loadDashboardDataAction->run()); } } \ No newline at end of file diff --git a/app/Containers/Dashboard/UI/WEB/Controllers/JournalIssueController.php b/app/Containers/Dashboard/UI/WEB/Controllers/JournalIssueController.php new file mode 100644 index 0000000..71629ac --- /dev/null +++ b/app/Containers/Dashboard/UI/WEB/Controllers/JournalIssueController.php @@ -0,0 +1,123 @@ +only(['search', 'year_publication', 'is_active']); + + $data = $this->listJournalIssuesAction->run($academicJournal->id, $filters); + + return Inertia::render('Dashboard/AcademicJournals/JournalIssues/Index', array_merge($data, [ + 'journal' => $academicJournal->only(['id', 'title', 'slug']), + ])); + } + + /** + * Show the form for creating a new journal issue + */ + public function create(AcademicJournal $academicJournal): Response + { + return Inertia::render('Dashboard/AcademicJournals/JournalIssues/Create', [ + 'journal' => $academicJournal->only(['id', 'title', 'slug']), + ]); + } + + /** + * Store a newly created journal issue + */ + public function store(StoreJournalIssueRequest $request, AcademicJournal $academicJournal): RedirectResponse + { + try { + $validated = $request->validated(); + $validated['academic_journal_id'] = $academicJournal->id; + + $this->createJournalIssueAction->run($validated); + + return redirect()->route('dashboard.academic-journals.issues.index', $academicJournal->id) + ->with('success', 'Выпуск журнала успешно создан!'); + } catch (\Exception $e) { + return back() + ->withInput() + ->with('error', 'Ошибка при создании выпуска: ' . $e->getMessage()); + } + } + + /** + * Show the form for editing the specified journal issue + */ + public function edit(AcademicJournal $academicJournal, int $issue): Response + { + $issue = $academicJournal->journals()->findOrFail($issue); + + return Inertia::render('Dashboard/AcademicJournals/JournalIssues/Edit', [ + 'journal' => $academicJournal->only(['id', 'title', 'slug']), + 'issue' => $issue, + ]); + } + + /** + * Update the specified journal issue + */ + public function update(UpdateJournalIssueRequest $request, AcademicJournal $academicJournal, int $issue): RedirectResponse + { + $issue = $academicJournal->journals()->findOrFail($issue); + + try { + $validated = $request->validated(); + + $this->updateJournalIssueAction->run($issue, $validated); + + return redirect()->route('dashboard.academic-journals.issues.index', $academicJournal->id) + ->with('success', 'Выпуск журнала успешно обновлен!'); + } catch (\Exception $e) { + return back() + ->withInput() + ->with('error', 'Ошибка при обновлении выпуска: ' . $e->getMessage()); + } + } + + /** + * Remove the specified journal issue + */ + public function destroy(AcademicJournal $academicJournal, int $issue): RedirectResponse + { + $issue = $academicJournal->journals()->findOrFail($issue); + + try { + $this->deleteJournalIssueAction->run($issue); + + return redirect()->route('dashboard.academic-journals.issues.index', $academicJournal->id) + ->with('success', 'Выпуск журнала успешно удален!'); + } catch (\Exception $e) { + return back() + ->with('error', 'Ошибка при удалении выпуска: ' . $e->getMessage()); + } + } +} diff --git a/app/Containers/Dashboard/UI/WEB/Controllers/MainSectionController.php b/app/Containers/Dashboard/UI/WEB/Controllers/MainSectionController.php new file mode 100644 index 0000000..0dd9696 --- /dev/null +++ b/app/Containers/Dashboard/UI/WEB/Controllers/MainSectionController.php @@ -0,0 +1,111 @@ +only(['search']); + + $data = $this->listMainSectionsAction->run($filters); + + return Inertia::render('Dashboard/MainSections/Index', $data); + } + + /** + * Показывает форму создания раздела + */ + public function create(): \Inertia\Response + { + return Inertia::render('Dashboard/MainSections/Create'); + } + + /** + * Создает новый главный раздел + */ + public function store(StoreMainSectionRequest $request): RedirectResponse + { + try { + $validated = $request->validated(); + + $this->createMainSectionAction->run($validated); + + return redirect()->route('dashboard.main-sections.index') + ->with('success', 'Главный раздел успешно создан!'); + } catch (\Exception $e) { + return back() + ->withInput() + ->with('error', 'Ошибка при создании главного раздела: ' . $e->getMessage()); + } + } + + /** + * Показывает форму редактирования раздела + */ + public function edit(MainSection $mainSection): \Inertia\Response + { + $mainSection->load(['subSections']); + + return Inertia::render('Dashboard/MainSections/Edit', [ + 'mainSection' => $mainSection, + ]); + } + + /** + * Обновляет существующий главный раздел + */ + public function update(UpdateMainSectionRequest $request, MainSection $mainSection): RedirectResponse + { + try { + $validated = $request->validated(); + + $this->updateMainSectionAction->run($mainSection, $validated); + + return redirect()->route('dashboard.main-sections.index') + ->with('success', 'Главный раздел успешно обновлен!'); + } catch (\Exception $e) { + return back() + ->withInput() + ->with('error', 'Ошибка при обновлении главного раздела: ' . $e->getMessage()); + } + } + + /** + * Удаляет главный раздел + */ + public function destroy(MainSection $mainSection): RedirectResponse + { + try { + $this->deleteMainSectionAction->run($mainSection); + + return redirect()->route('dashboard.main-sections.index') + ->with('success', 'Главный раздел успешно удален!'); + } catch (\Exception $e) { + return back() + ->with('error', 'Ошибка при удалении главного раздела: ' . $e->getMessage()); + } + } +} diff --git a/app/Containers/Dashboard/UI/WEB/Controllers/PageController.php b/app/Containers/Dashboard/UI/WEB/Controllers/PageController.php new file mode 100644 index 0000000..42958f9 --- /dev/null +++ b/app/Containers/Dashboard/UI/WEB/Controllers/PageController.php @@ -0,0 +1,118 @@ +only(['search', 'tab', 'sub_section_id']); + + $data = $this->listPagesAction->run($filters); + + return Inertia::render('Dashboard/Pages/Index', $data); + } + + /** + * Показывает форму создания страницы + */ + public function create(): \Inertia\Response + { + $data = $this->listPagesAction->run([]); + + return Inertia::render('Dashboard/Pages/Create', [ + 'subSections' => $data['subSections'], + ]); + } + + /** + * Создает новую страницу + */ + public function store(StorePageRequest $request): RedirectResponse + { + try { + $validated = $request->validated(); + + $this->createPageAction->run($validated); + + return redirect()->route('dashboard.pages.index') + ->with('success', 'Страница успешно создана!'); + } catch (\Exception $e) { + return back() + ->withInput() + ->with('error', 'Ошибка при создании страницы: ' . $e->getMessage()); + } + } + + /** + * Показывает форму редактирования страницы + */ + public function edit(Page $page): \Inertia\Response + { + $page->load(['section.mainSection']); + + $data = $this->listPagesAction->run([]); + + return Inertia::render('Dashboard/Pages/Edit', [ + 'page' => $page, + 'subSections' => $data['subSections'], + ]); + } + + /** + * Обновляет существующую страницу + */ + public function update(UpdatePageRequest $request, Page $page): RedirectResponse + { + try { + $validated = $request->validated(); + + $this->updatePageAction->run($page, $validated); + + return redirect()->route('dashboard.pages.index') + ->with('success', 'Страница успешно обновлена!'); + } catch (\Exception $e) { + return back() + ->withInput() + ->with('error', 'Ошибка при обновлении страницы: ' . $e->getMessage()); + } + } + + /** + * Удаляет страницу + */ + public function destroy(Page $page): RedirectResponse + { + try { + $this->deletePageAction->run($page); + + return redirect()->route('dashboard.pages.index') + ->with('success', 'Страница успешно удалена!'); + } catch (\Exception $e) { + return back() + ->with('error', 'Ошибка при удалении страницы: ' . $e->getMessage()); + } + } +} diff --git a/app/Containers/Dashboard/UI/WEB/Controllers/PageReferenceListController.php b/app/Containers/Dashboard/UI/WEB/Controllers/PageReferenceListController.php new file mode 100644 index 0000000..88afe7e --- /dev/null +++ b/app/Containers/Dashboard/UI/WEB/Controllers/PageReferenceListController.php @@ -0,0 +1,83 @@ +only(['search', 'is_active']); + $data = $this->listPageReferenceListsAction->run($filters); + + return Inertia::render('Dashboard/PageReferenceLists/Index', $data); + } + + public function create(): \Inertia\Response + { + return Inertia::render('Dashboard/PageReferenceLists/Create'); + } + + public function store(StorePageReferenceListRequest $request): RedirectResponse + { + try { + $validated = $request->validated(); + $this->createPageReferenceListAction->run($validated); + + return redirect()->route('dashboard.page-reference-lists.index') + ->with('success', 'Список ресурсов успешно создан!'); + } catch (\Exception $e) { + return back()->withInput()->with('error', 'Ошибка при создании: ' . $e->getMessage()); + } + } + + public function edit(PageReferenceList $pageReferenceList): \Inertia\Response + { + return Inertia::render('Dashboard/PageReferenceLists/Edit', [ + 'list' => $pageReferenceList, + ]); + } + + public function update(UpdatePageReferenceListRequest $request, PageReferenceList $pageReferenceList): RedirectResponse + { + try { + $validated = $request->validated(); + $this->updatePageReferenceListAction->run($pageReferenceList, $validated); + + return redirect()->route('dashboard.page-reference-lists.index') + ->with('success', 'Список ресурсов успешно обновлён!'); + } catch (\Exception $e) { + return back()->withInput()->with('error', 'Ошибка при обновлении: ' . $e->getMessage()); + } + } + + public function destroy(PageReferenceList $pageReferenceList): RedirectResponse + { + try { + $this->deletePageReferenceListAction->run($pageReferenceList); + + return redirect()->route('dashboard.page-reference-lists.index') + ->with('success', 'Список ресурсов успешно удалён!'); + } catch (\Exception $e) { + return back()->with('error', 'Ошибка при удалении: ' . $e->getMessage()); + } + } +} diff --git a/app/Containers/Dashboard/UI/WEB/Controllers/ParseEmailNewsController.php b/app/Containers/Dashboard/UI/WEB/Controllers/ParseEmailNewsController.php new file mode 100644 index 0000000..f3da0fb --- /dev/null +++ b/app/Containers/Dashboard/UI/WEB/Controllers/ParseEmailNewsController.php @@ -0,0 +1,55 @@ +fetchEmailNewsAction->run(); + + if ($result['created_posts'] > 0) { + return redirect()->back()->with('success', + "Успешно обработано писем: {$result['processed_emails']}. Создано новостей: {$result['created_posts']}" + ); + } + + if ($result['processed_emails'] === 0) { + return redirect()->back()->with('info', + 'Нет непрочитанных писем для обработки' + ); + } + + return redirect()->back()->with('warning', + "Обработано писем: {$result['processed_emails']}, но новостей не создано" + ); + } catch (EmailFetchException $e) { + Log::error('[ParseEmailNewsController] EmailFetchException', [ + 'error' => $e->getMessage(), + ]); + + return redirect()->back()->with('error', $e->getMessage()); + } catch (\Exception $e) { + Log::error('[ParseEmailNewsController] Критическая ошибка', [ + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + + return redirect()->back()->with('error', 'Ошибка при парсинге email: ' . $e->getMessage()); + } + } +} diff --git a/app/Containers/Dashboard/UI/WEB/Controllers/PostController.php b/app/Containers/Dashboard/UI/WEB/Controllers/PostController.php new file mode 100644 index 0000000..2131911 --- /dev/null +++ b/app/Containers/Dashboard/UI/WEB/Controllers/PostController.php @@ -0,0 +1,218 @@ +getPostFormDataAction->run(); + + return Inertia::render('Dashboard/Posts/Create', $formData); + } + + /** + * Создает новый пост + */ + public function store(StorePostRequest $request): RedirectResponse + { + try { + $this->createPostAction->run($request->validated()); + + return redirect()->route('dashboard.posts.index') + ->with('success', 'Новость успешно создана!'); + } catch (\Exception $e) { + return back() + ->withInput() + ->with('error', 'Ошибка при создании новости: ' . $e->getMessage()); + } + } + + /** + * Показывает форму редактирования поста + */ + public function edit(Post $post): \Inertia\Response + { + $post->load(['category', 'seo']); + $formData = $this->getPostFormDataAction->run(); + + return Inertia::render('Dashboard/Posts/Edit', [ + 'post' => [ + ...$post->toArray(), + 'publish_setting' => [ + 'publish_after' => $post->publish_at !== null, + 'publish_at' => $post->publish_at, + ], + 'publication' => [ + 'vk' => true, + 'telegram' => true, + ], + ], + ...$formData, + ]); + } + + /** + * Обновляет существующий пост + */ + public function update(StorePostRequest $request, Post $post): RedirectResponse + { + try { + $this->updatePostAction->run($post, $request->validated()); + + return redirect()->route('dashboard.posts.index') + ->with('success', 'Новость успешно обновлена!'); + } catch (\Exception $e) { + return back() + ->withInput() + ->with('error', 'Ошибка при обновлении новости: ' . $e->getMessage()); + } + } + + /** + * Показывает список постов + */ + public function index(Request $request): \Inertia\Response + { + $filters = [ + 'status' => $request->status, + 'search' => $request->search, + ]; + + $posts = $this->listPostsAction->run($filters, 20); + + return Inertia::render('Dashboard/Posts/Index', [ + 'posts' => $posts->withQueryString(), + 'filters' => $filters, + ]); + } + + /** + * Показывает AI подготовленные посты для модерации + */ + public function aiPrepared(Request $request): \Inertia\Response + { + $aiPreparedPosts = $this->listAiPreparedPostsAction->run(); + + return Inertia::render('Dashboard/Posts/AiPrepared', [ + 'aiPreparedPosts' => $aiPreparedPosts, + ]); + } + + /** + * Показывает просмотр поста + */ + public function show(Post $post): \Inertia\Response + { + $post->load(['category', 'author', 'slide', 'tags']); + + return Inertia::render('Dashboard/Posts/Show', [ + 'post' => [ + ...$post->toArray(), + 'slide' => $post->slide?->toArray(), + ], + ]); + } + + /** + * Удаляет пост + */ + public function destroy(Post $post): RedirectResponse + { + try { + $this->deletePostAction->run($post); + + return redirect()->route('dashboard.posts.index') + ->with('success', 'Новость успешно удалена!'); + } catch (\Exception $e) { + return back() + ->with('error', 'Ошибка при удалении новости: ' . $e->getMessage()); + } + } + + /** + * Массовое удаление постов + */ + public function bulkDestroy(Request $request): RedirectResponse + { + $request->validate(['ids' => 'required|array', 'ids.*' => 'integer|exists:posts,id']); + + try { + $count = $this->bulkDeletePostsAction->run($request->ids); + + return redirect()->back() + ->with('success', "Удалено {$count} новостей"); + } catch (\Exception $e) { + return back() + ->with('error', 'Ошибка при удалении: ' . $e->getMessage()); + } + } + + /** + * Массовая публикация постов + */ + public function bulkPublish(Request $request): RedirectResponse + { + $request->validate(['ids' => 'required|array', 'ids.*' => 'integer|exists:posts,id']); + + try { + $count = $this->bulkPublishPostsAction->run($request->ids); + + return redirect()->back() + ->with('success', "Опубликовано {$count} новостей"); + } catch (\Exception $e) { + return back() + ->with('error', 'Ошибка при публикации: ' . $e->getMessage()); + } + } + + /** + * Массовая установка статуса "На модерации" + */ + public function bulkVerification(Request $request): RedirectResponse + { + $request->validate(['ids' => 'required|array', 'ids.*' => 'integer|exists:posts,id']); + + try { + $count = $this->bulkVerificationPostsAction->run($request->ids); + + return redirect()->back() + ->with('success', "{$count} новостей переведено на модерацию"); + } catch (\Exception $e) { + return back() + ->with('error', 'Ошибка: ' . $e->getMessage()); + } + } +} diff --git a/app/Containers/Dashboard/UI/WEB/Controllers/ProcessMixedFilesController.php b/app/Containers/Dashboard/UI/WEB/Controllers/ProcessMixedFilesController.php index 2922c32..2d20f06 100644 --- a/app/Containers/Dashboard/UI/WEB/Controllers/ProcessMixedFilesController.php +++ b/app/Containers/Dashboard/UI/WEB/Controllers/ProcessMixedFilesController.php @@ -2,7 +2,7 @@ namespace App\Containers\Dashboard\UI\WEB\Controllers; -use App\Containers\Dashboard\Actions\ProcessMixedFilesAction; +use App\Containers\Dashboard\Actions\EmailNews\ProcessMixedFilesAction; use App\Containers\Dashboard\UI\WEB\Requests\StoreFilesRequest; use App\Http\Controllers\Controller; use Illuminate\Http\RedirectResponse; diff --git a/app/Containers/Dashboard/UI/WEB/Controllers/PublishPostController.php b/app/Containers/Dashboard/UI/WEB/Controllers/PublishPostController.php index 4508098..4695d72 100644 --- a/app/Containers/Dashboard/UI/WEB/Controllers/PublishPostController.php +++ b/app/Containers/Dashboard/UI/WEB/Controllers/PublishPostController.php @@ -3,7 +3,7 @@ namespace App\Containers\Dashboard\UI\WEB\Controllers; use App\Containers\Article\Models\Post; -use App\Containers\Dashboard\Actions\PublishPostAction; +use App\Containers\Dashboard\Actions\Posts\PublishPostAction; use App\Http\Controllers\Controller; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; diff --git a/app/Containers/Dashboard/UI/WEB/Controllers/QuickUploadController.php b/app/Containers/Dashboard/UI/WEB/Controllers/QuickUploadController.php new file mode 100644 index 0000000..7df4132 --- /dev/null +++ b/app/Containers/Dashboard/UI/WEB/Controllers/QuickUploadController.php @@ -0,0 +1,43 @@ +validate([ + 'file' => ['required', 'file', 'max:20000'], + ]); + + try { + $result = $quickUploadFileAction->run($request->file('file')); + + return response()->json([ + 'success' => true, + 'data' => [ + 'url' => url($result['url']), + 'path' => $result['path'], + 'original_name' => $result['original_name'], + ], + ]); + } catch (\Throwable $e) { + report($e); + + return response()->json([ + 'success' => false, + 'message' => 'Ошибка при загрузке файла: ' . $e->getMessage(), + ], 500); + } + } +} diff --git a/app/Containers/Dashboard/UI/WEB/Controllers/ScheduleController.php b/app/Containers/Dashboard/UI/WEB/Controllers/ScheduleController.php new file mode 100644 index 0000000..4e6fcf8 --- /dev/null +++ b/app/Containers/Dashboard/UI/WEB/Controllers/ScheduleController.php @@ -0,0 +1,153 @@ +only(['search', 'educational_group_id', 'education_form_id']); + + $data = $this->listSchedulesAction->run($filters); + + return Inertia::render('Dashboard/Schedules/Index', $data); + } + + /** + * Показывает форму создания расписания + */ + public function create(): \Inertia\Response + { + $data = $this->listSchedulesAction->run([]); + + return Inertia::render('Dashboard/Schedules/Create', [ + 'educationalGroups' => $data['educationalGroups'], + 'educationForms' => $data['educationForms'], + ]); + } + + /** + * Создает новое расписание + */ + public function store(StoreScheduleRequest $request): RedirectResponse + { + try { + $validated = $request->validated(); + + // Обработка файла + if (!empty($validated['file'][0]['path'])) { + $file = $validated['file'][0]['path']; + $filename = Str::slug(pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME) . '-' . Carbon::now()->timestamp) . '.' . $file->getClientOriginalExtension(); + $path = $file->storeAs('schedules', $filename, 'public'); + + $validated['file'] = [ + [ + 'title' => $validated['file'][0]['title'], + 'path' => $path, + ], + ]; + } + + $this->createScheduleAction->run($validated); + + return redirect()->route('dashboard.schedules.index') + ->with('success', 'Расписание успешно создано!'); + } catch (\Exception $e) { + return back() + ->withInput() + ->with('error', 'Ошибка при создании расписания: ' . $e->getMessage()); + } + } + + /** + * Показывает форму редактирования расписания + */ + public function edit(Schedule $schedule): \Inertia\Response + { + $schedule->load(['educationalGroup.faculty']); + + $data = $this->listSchedulesAction->run([]); + + return Inertia::render('Dashboard/Schedules/Edit', [ + 'schedule' => $schedule, + 'educationalGroups' => $data['educationalGroups'], + 'educationForms' => $data['educationForms'], + ]); + } + + /** + * Обновляет существующее расписание + */ + public function update(UpdateScheduleRequest $request, Schedule $schedule): RedirectResponse + { + try { + $validated = $request->validated(); + + // Обработка нового файла если загружен + if (!empty($validated['file'][0]['path'])) { + $file = $validated['file'][0]['path']; + $filename = Str::slug(pathinfo($file->getClientOriginalName(), PATHINFO_FILENAME) . '-' . Carbon::now()->timestamp) . '.' . $file->getClientOriginalExtension(); + $path = $file->storeAs('schedules', $filename, 'public'); + + $validated['file'] = [ + [ + 'title' => $validated['file'][0]['title'], + 'path' => $path, + ], + ]; + } else { + // Оставляем старый файл + unset($validated['file']); + } + + $this->updateScheduleAction->run($schedule, $validated); + + return redirect()->route('dashboard.schedules.index') + ->with('success', 'Расписание успешно обновлено!'); + } catch (\Exception $e) { + return back() + ->withInput() + ->with('error', 'Ошибка при обновлении расписания: ' . $e->getMessage()); + } + } + + /** + * Удаляет расписание + */ + public function destroy(Schedule $schedule): RedirectResponse + { + try { + $this->deleteScheduleAction->run($schedule); + + return redirect()->route('dashboard.schedules.index') + ->with('success', 'Расписание успешно удалено!'); + } catch (\Exception $e) { + return back() + ->with('error', 'Ошибка при удалении расписания: ' . $e->getMessage()); + } + } +} diff --git a/app/Containers/Dashboard/UI/WEB/Controllers/SliderController.php b/app/Containers/Dashboard/UI/WEB/Controllers/SliderController.php new file mode 100644 index 0000000..7bca55d --- /dev/null +++ b/app/Containers/Dashboard/UI/WEB/Controllers/SliderController.php @@ -0,0 +1,25 @@ +only(['search', 'is_active']); + $sliders = $this->listSlidersAction->run($filters); + + return inertia()->render('Dashboard/Sliders/Index', [ + 'sliders' => $sliders, + 'filters' => $filters, + ]); + } +} diff --git a/app/Containers/Dashboard/UI/WEB/Controllers/StoreFilesController.php b/app/Containers/Dashboard/UI/WEB/Controllers/StoreFilesController.php index 524da62..e17c83c 100644 --- a/app/Containers/Dashboard/UI/WEB/Controllers/StoreFilesController.php +++ b/app/Containers/Dashboard/UI/WEB/Controllers/StoreFilesController.php @@ -2,7 +2,7 @@ namespace App\Containers\Dashboard\UI\WEB\Controllers; -use App\Containers\Dashboard\Actions\ProcessUploadedFilesAction; +use App\Containers\Dashboard\Actions\EmailNews\ProcessUploadedFilesAction; use App\Containers\Dashboard\UI\WEB\Requests\StoreFilesRequest; use App\Http\Controllers\Controller; use Illuminate\Http\RedirectResponse; diff --git a/app/Containers/Dashboard/UI/WEB/Controllers/StoreSlideController.php b/app/Containers/Dashboard/UI/WEB/Controllers/StoreSlideController.php new file mode 100644 index 0000000..3fb409b --- /dev/null +++ b/app/Containers/Dashboard/UI/WEB/Controllers/StoreSlideController.php @@ -0,0 +1,26 @@ +createSlideAction->run($slider, $request->validated()); + + return response()->json([ + 'success' => true, + 'slide' => $slide, + ]); + } +} diff --git a/app/Containers/Dashboard/UI/WEB/Controllers/StoreSliderController.php b/app/Containers/Dashboard/UI/WEB/Controllers/StoreSliderController.php new file mode 100644 index 0000000..e0ac835 --- /dev/null +++ b/app/Containers/Dashboard/UI/WEB/Controllers/StoreSliderController.php @@ -0,0 +1,23 @@ +createSliderAction->run($request->validated()); + + return redirect()->route('dashboard.sliders.edit', $slider->id) + ->with('success', 'Слайдер успешно создан'); + } +} diff --git a/app/Containers/Dashboard/UI/WEB/Controllers/SubSectionController.php b/app/Containers/Dashboard/UI/WEB/Controllers/SubSectionController.php new file mode 100644 index 0000000..836416f --- /dev/null +++ b/app/Containers/Dashboard/UI/WEB/Controllers/SubSectionController.php @@ -0,0 +1,203 @@ +only(['search', 'main_section_id']); + + $data = $this->listSubSectionsAction->run($filters); + $data['mainSections'] = MainSection::pluck('title', 'id'); + + return Inertia::render('Dashboard/SubSections/Index', $data); + } + + /** + * Показывает форму создания подраздела + */ + public function create(): \Inertia\Response + { + $mainSections = MainSection::pluck('title', 'id'); + + return Inertia::render('Dashboard/SubSections/Create', [ + 'mainSections' => $mainSections, + ]); + } + + /** + * Создает новый подраздел + */ + public function store(StoreSubSectionRequest $request): RedirectResponse + { + try { + $validated = $request->validated(); + + $this->createSubSectionAction->run($validated); + + return redirect()->route('dashboard.sub-sections.index') + ->with('success', 'Подраздел успешно создан!'); + } catch (\Exception $e) { + return back() + ->withInput() + ->with('error', 'Ошибка при создании подраздела: ' . $e->getMessage()); + } + } + + /** + * Показывает форму редактирования подраздела + */ + public function edit(SubSection $subSection): \Inertia\Response + { + $subSection->load(['mainSection', 'pages']); + + $mainSections = MainSection::pluck('title', 'id'); + $availablePages = Page::whereNull('sub_section_id') + ->whereNotNull('title') + ->pluck('title', 'id'); + + return Inertia::render('Dashboard/SubSections/Edit', [ + 'subSection' => $subSection, + 'mainSections' => $mainSections, + 'availablePages' => $availablePages, + ]); + } + + /** + * Обновляет существующий подраздел + */ + public function update(UpdateSubSectionRequest $request, SubSection $subSection): RedirectResponse + { + try { + $validated = $request->validated(); + + $this->updateSubSectionAction->run($subSection, $validated); + + return redirect()->route('dashboard.sub-sections.index') + ->with('success', 'Подраздел успешно обновлен!'); + } catch (\Exception $e) { + return back() + ->withInput() + ->with('error', 'Ошибка при обновлении подраздела: ' . $e->getMessage()); + } + } + + /** + * Удаляет подраздел + */ + public function destroy(SubSection $subSection): RedirectResponse + { + try { + $this->deleteSubSectionAction->run($subSection); + + return redirect()->route('dashboard.sub-sections.index') + ->with('success', 'Подраздел успешно удален!'); + } catch (\Exception $e) { + return back() + ->with('error', 'Ошибка при удалении подраздела: ' . $e->getMessage()); + } + } + + /** + * Прикрепляет подраздел к главному разделу + */ + public function attachToMainSection(Request $request, SubSection $subSection): RedirectResponse + { + $request->validate([ + 'main_section_id' => ['required', 'exists:main_sections,id'], + ]); + + try { + $mainSection = MainSection::findOrFail($request->main_section_id); + $this->attachSubSectionAction->run($subSection, $mainSection); + + return back()->with('success', 'Подраздел прикреплен к главному разделу!'); + } catch (\Exception $e) { + return back()->with('error', 'Ошибка при прикреплении подраздела: ' . $e->getMessage()); + } + } + + /** + * Открепляет подраздел от главного раздела + */ + public function detachFromMainSection(SubSection $subSection): RedirectResponse + { + try { + $this->detachSubSectionAction->run($subSection); + + return back()->with('success', 'Подраздел откреплен от главного раздела!'); + } catch (\Exception $e) { + return back()->with('error', 'Ошибка при откреплении подраздела: ' . $e->getMessage()); + } + } + + /** + * Прикрепляет страницу к подразделу + */ + public function attachPage(Request $request, SubSection $subSection): RedirectResponse + { + $request->validate([ + 'page_id' => ['required', 'exists:pages,id'], + ]); + + try { + $page = Page::findOrFail($request->page_id); + $this->attachPageAction->run($page, $subSection); + + return back()->with('success', 'Страница прикреплена к подразделу!'); + } catch (\Exception $e) { + return back()->with('error', 'Ошибка при прикреплении страницы: ' . $e->getMessage()); + } + } + + /** + * Открепляет страницу от подраздела + */ + public function detachPage(SubSection $subSection, Page $page): RedirectResponse + { + try { + if ($page->sub_section_id !== $subSection->id) { + abort(403, 'Страница не принадлежит этому подразделу'); + } + + $this->detachPageAction->run($page); + + return back()->with('success', 'Страница откреплена от подраздела!'); + } catch (\Exception $e) { + return back()->with('error', 'Ошибка при откреплении страницы: ' . $e->getMessage()); + } + } +} diff --git a/app/Containers/Dashboard/UI/WEB/Controllers/UpdateSlideController.php b/app/Containers/Dashboard/UI/WEB/Controllers/UpdateSlideController.php new file mode 100644 index 0000000..6e67345 --- /dev/null +++ b/app/Containers/Dashboard/UI/WEB/Controllers/UpdateSlideController.php @@ -0,0 +1,26 @@ +updateSlideAction->run($slide, $request->validated()); + + return response()->json([ + 'success' => true, + 'slide' => $slide, + ]); + } +} diff --git a/app/Containers/Dashboard/UI/WEB/Controllers/UpdateSliderController.php b/app/Containers/Dashboard/UI/WEB/Controllers/UpdateSliderController.php new file mode 100644 index 0000000..99d5976 --- /dev/null +++ b/app/Containers/Dashboard/UI/WEB/Controllers/UpdateSliderController.php @@ -0,0 +1,24 @@ +updateSliderAction->run($slider, $request->validated()); + + return redirect()->back() + ->with('success', 'Слайдер успешно обновлен'); + } +} diff --git a/app/Containers/Dashboard/UI/WEB/Controllers/UpdateSlidesOrderController.php b/app/Containers/Dashboard/UI/WEB/Controllers/UpdateSlidesOrderController.php new file mode 100644 index 0000000..cf39227 --- /dev/null +++ b/app/Containers/Dashboard/UI/WEB/Controllers/UpdateSlidesOrderController.php @@ -0,0 +1,30 @@ +validate([ + 'slide_ids' => 'required|array', + 'slide_ids.*' => 'required|integer|exists:slides,id', + ]); + + $this->updateSlidesOrderAction->run($slider, $request->input('slide_ids')); + + return response()->json([ + 'success' => true, + ]); + } +} diff --git a/app/Containers/Dashboard/UI/WEB/Controllers/UploadSchedulesController.php b/app/Containers/Dashboard/UI/WEB/Controllers/UploadSchedulesController.php new file mode 100644 index 0000000..0a9de07 --- /dev/null +++ b/app/Containers/Dashboard/UI/WEB/Controllers/UploadSchedulesController.php @@ -0,0 +1,96 @@ +file('files', []); + + // Если files - это один файл (не массив), преобразуем в массив + if (!is_array($files)) { + $files = [$files]; + } + + // Валидация каждого файла + $validator = \Illuminate\Support\Facades\Validator::make( + ['files' => $files], + [ + 'files' => 'required|array', + 'files.*' => 'required|file|mimes:pdf|max:10000', + ], + [ + 'files.required' => 'Выберите файлы для загрузки', + 'files.*.mimes' => 'Разрешены только PDF файлы', + 'files.*.max' => 'Размер файла не должен превышать 10MB', + ] + ); + + if ($validator->fails()) { + return Inertia::render('Dashboard/Schedules/Upload', [ + 'flash' => [ + 'message' => $validator->errors()->first(), + 'type' => 'error', + ], + ])->withInput(); + } + + if (empty($files)) { + return Inertia::render('Dashboard/Schedules/Upload', [ + 'flash' => [ + 'message' => 'Нет файлов для обработки', + 'type' => 'error', + ], + ]); + } + + $result = $this->uploadMultipleSchedulesAction->run($files); + + $hasProcessed = $result['processed_count'] > 0; + $hasFailed = $result['failed_count'] > 0; + + $message = ''; + $messageType = 'success'; + + if ($hasProcessed && $hasFailed) { + $message = "Успешно: {$result['processed_count']}. Ошибки: {$result['failed_count']}."; + $messageType = 'warning'; + } elseif ($hasProcessed) { + $message = "Успешно загружено: {$result['processed_count']} файл(ов)"; + $messageType = 'success'; + } elseif ($hasFailed) { + $message = "Ошибки при обработке: {$result['failed_count']} файл(ов)"; + $messageType = 'error'; + } + + return Inertia::render('Dashboard/Schedules/Upload', [ + 'flash' => [ + 'message' => $message, + 'type' => $messageType, + ], + 'data' => $result, + ]); + } +} diff --git a/app/Containers/Dashboard/UI/WEB/Controllers/UserController.php b/app/Containers/Dashboard/UI/WEB/Controllers/UserController.php new file mode 100644 index 0000000..5c62a87 --- /dev/null +++ b/app/Containers/Dashboard/UI/WEB/Controllers/UserController.php @@ -0,0 +1,160 @@ +only(['search', 'role_id']); + + $data = $this->listUsersAction->run($filters); + + return Inertia::render('Dashboard/Users/Index', $data); + } + + /** + * Show the form for creating a new user + */ + public function create(): Response + { + $data = $this->listUsersAction->run([]); + + return Inertia::render('Dashboard/Users/Create', [ + 'roles' => $data['roles'], + ]); + } + + /** + * Store a newly created user + */ + public function store(StoreUserRequest $request): RedirectResponse + { + try { + $validated = $request->validated(); + + $this->createUserAction->run($validated); + + return redirect()->route('dashboard.users.index') + ->with('success', 'Пользователь успешно создан!'); + } catch (\Exception $e) { + return back() + ->withInput() + ->with('error', 'Ошибка при создании пользователя: ' . $e->getMessage()); + } + } + + /** + * Show the form for editing the specified user + */ + public function edit(User $user): Response + { + $user->load(['roles', 'permissions', 'userDetail']); + + $data = $this->listUsersAction->run([]); + + return Inertia::render('Dashboard/Users/Edit', [ + 'user' => [ + 'id' => $user->id, + 'name' => $user->name, + 'email' => $user->email, + 'slug' => $user->slug, + 'roles' => $user->roles->map(fn($role) => [ + 'id' => $role->id, + 'name' => $role->name, + ]), + 'permissions' => $user->permissions->map(fn($perm) => [ + 'name' => $perm->name, + ]), + 'user_detail' => $user->userDetail, + 'created_at' => $user->created_at, + 'updated_at' => $user->updated_at, + ], + 'roles' => $data['roles']->map(fn($role) => [ + 'id' => $role->id, + 'name' => $role->name, + ]), + 'permissions' => $data['roles']->flatMap(function ($role) { + return $role->permissions; + })->unique('name')->values(), + ]); + } + + /** + * Update the specified user + */ + public function update(UpdateUserRequest $request, User $user): RedirectResponse + { + try { + $validated = $request->validated(); + + $this->updateUserAction->run($user, $validated); + + return redirect()->route('dashboard.users.edit', $user->id) + ->with('success', 'Пользователь успешно обновлен!'); + } catch (\Exception $e) { + return back() + ->withInput() + ->with('error', 'Ошибка при обновлении пользователя: ' . $e->getMessage()); + } + } + + /** + * Remove the specified user + */ + public function destroy(User $user): RedirectResponse + { + try { + $this->deleteUserAction->run($user); + + return redirect()->route('dashboard.users.index') + ->with('success', 'Пользователь успешно удален!'); + } catch (\Exception $e) { + return back() + ->with('error', 'Ошибка при удалении пользователя: ' . $e->getMessage()); + } + } + + /** + * Invite a new user via email + */ + public function invite(Request $request): RedirectResponse + { + $request->validate([ + 'email' => ['required', 'email', 'max:255'], + ]); + + $result = $this->inviteUserAction->run($request->email, auth()->id()); + + if ($result['success']) { + return back()->with('success', $result['message']); + } + + return back()->with('error', $result['message']); + } +} diff --git a/app/Containers/Dashboard/UI/WEB/Controllers/UserDetailController.php b/app/Containers/Dashboard/UI/WEB/Controllers/UserDetailController.php new file mode 100644 index 0000000..316a941 --- /dev/null +++ b/app/Containers/Dashboard/UI/WEB/Controllers/UserDetailController.php @@ -0,0 +1,165 @@ + $user->only(['id', 'name', 'email']), + ]); + } + + /** + * Store a newly created user detail + */ + public function store(StoreUserDetailRequest $request, User $user): RedirectResponse + { + try { + $validated = $request->validated(); + + // Обработка загруженного фото + if ($request->hasFile('photo')) { + $validated['photo'] = $this->handlePhotoUpload($request->file('photo')); + } + + // Декодирование JSON полей + $validated = $this->decodeJsonFields($validated); + + $this->createUserDetailAction->run($user->id, $validated); + + return redirect()->route('dashboard.users.edit', $user->id) + ->with('success', 'Детальная информация успешно добавлена!'); + } catch (\Exception $e) { + return back() + ->withInput() + ->with('error', 'Ошибка при добавлении информации: ' . $e->getMessage()); + } + } + + /** + * Show the form for editing user detail + */ + public function edit(User $user, UserDetail $userDetail): Response + { + // Проверяем принадлежность userDetail к user + if ($userDetail->user_id !== $user->id) { + abort(403, 'Unauthorized access.'); + } + + return Inertia::render('Dashboard/Users/UserDetail/Edit', [ + 'user' => $user->only(['id', 'name', 'email']), + 'userDetail' => $userDetail, + ]); + } + + /** + * Update the specified user detail + */ + public function update(UpdateUserDetailRequest $request, User $user, UserDetail $userDetail): RedirectResponse + { + // Проверяем принадлежность userDetail к user + if ($userDetail->user_id !== $user->id) { + abort(403, 'Unauthorized access.'); + } + + try { + $validated = $request->validated(); + + // Обработка загруженного фото + if ($request->hasFile('photo')) { + $validated['photo'] = $this->handlePhotoUpload($request->file('photo')); + } + + // Декодирование JSON полей + $validated = $this->decodeJsonFields($validated); + + $this->updateUserDetailAction->run($userDetail, $validated); + + return redirect()->route('dashboard.users.edit', $user->id) + ->with('success', 'Детальная информация успешно обновлена!'); + } catch (\Exception $e) { + return back() + ->withInput() + ->with('error', 'Ошибка при обновлении информации: ' . $e->getMessage()); + } + } + + /** + * Remove the specified user detail + */ + public function destroy(User $user, UserDetail $userDetail): RedirectResponse + { + // Проверяем принадлежность userDetail к user + if ($userDetail->user_id !== $user->id) { + abort(403, 'Unauthorized access.'); + } + + try { + $this->deleteUserDetailAction->run($userDetail); + + return redirect()->route('dashboard.users.edit', $user->id) + ->with('success', 'Детальная информация успешно удалена!'); + } catch (\Exception $e) { + return back() + ->with('error', 'Ошибка при удалении информации: ' . $e->getMessage()); + } + } + + /** + * Handle photo upload and return file path + */ + private function handlePhotoUpload($file): string + { + $path = $file->store('images', 'public'); + return $path; + } + + /** + * Decode JSON fields from request data + */ + private function decodeJsonFields(array $data): array + { + $jsonFields = [ + 'workExperience', + 'education', + 'professionalRetraining', + 'professionalDevelopment', + 'awards', + 'professDisciplines', + 'attendedConferences', + 'publications', + 'other', + ]; + + foreach ($jsonFields as $field) { + if (isset($data[$field]) && is_string($data[$field])) { + $data[$field] = json_decode($data[$field], true); + } + } + + return $data; + } +} diff --git a/app/Containers/Dashboard/UI/WEB/Middleware/EnsureDashboardAuthenticated.php b/app/Containers/Dashboard/UI/WEB/Middleware/EnsureDashboardAuthenticated.php new file mode 100644 index 0000000..5e9db34 --- /dev/null +++ b/app/Containers/Dashboard/UI/WEB/Middleware/EnsureDashboardAuthenticated.php @@ -0,0 +1,23 @@ +route('login'); + } + + return $next($request); + } +} diff --git a/app/Containers/Dashboard/UI/WEB/Requests/AttachDepartmentProgramRequest.php b/app/Containers/Dashboard/UI/WEB/Requests/AttachDepartmentProgramRequest.php new file mode 100644 index 0000000..968d592 --- /dev/null +++ b/app/Containers/Dashboard/UI/WEB/Requests/AttachDepartmentProgramRequest.php @@ -0,0 +1,28 @@ + ['required', 'exists:educational_programs,id'], + ]; + } + + public function messages(): array + { + return [ + 'program_id.required' => 'Необходимо выбрать образовательную программу', + 'program_id.exists' => 'Выбранная программа не существует', + ]; + } +} diff --git a/app/Containers/Dashboard/UI/WEB/Requests/AttachDepartmentTeacherRequest.php b/app/Containers/Dashboard/UI/WEB/Requests/AttachDepartmentTeacherRequest.php new file mode 100644 index 0000000..7e695bb --- /dev/null +++ b/app/Containers/Dashboard/UI/WEB/Requests/AttachDepartmentTeacherRequest.php @@ -0,0 +1,35 @@ + ['required', 'exists:users,id'], + 'teaching_position' => ['required', 'string', 'max:255'], + 'service_email' => ['nullable', 'email', 'max:255'], + 'service_phone' => ['nullable', 'string', 'max:20'], + 'cabinet' => ['nullable', 'string', 'max:10'], + ]; + } + + public function messages(): array + { + return [ + 'user_id.required' => 'Необходимо выбрать преподавателя', + 'user_id.exists' => 'Выбранный преподаватель не существует', + 'teaching_position.required' => 'Должность обязательна для заполнения', + 'teaching_position.max' => 'Должность не должна превышать 255 символов', + 'service_email.email' => 'Введите корректный email адрес', + ]; + } +} diff --git a/app/Containers/Dashboard/UI/WEB/Requests/AttachDepartmentWorkerRequest.php b/app/Containers/Dashboard/UI/WEB/Requests/AttachDepartmentWorkerRequest.php new file mode 100644 index 0000000..1365bb1 --- /dev/null +++ b/app/Containers/Dashboard/UI/WEB/Requests/AttachDepartmentWorkerRequest.php @@ -0,0 +1,35 @@ + ['required', 'exists:users,id'], + 'position' => ['required', 'string', 'max:255'], + 'service_email' => ['nullable', 'email', 'max:255'], + 'service_phone' => ['nullable', 'string', 'max:20'], + 'cabinet' => ['nullable', 'string', 'max:10'], + ]; + } + + public function messages(): array + { + return [ + 'user_id.required' => 'Необходимо выбрать сотрудника', + 'user_id.exists' => 'Выбранный сотрудник не существует', + 'position.required' => 'Должность обязательна для заполнения', + 'position.max' => 'Должность не должна превышать 255 символов', + 'service_email.email' => 'Введите корректный email адрес', + ]; + } +} diff --git a/app/Containers/Dashboard/UI/WEB/Requests/AttachDivisionWorkerRequest.php b/app/Containers/Dashboard/UI/WEB/Requests/AttachDivisionWorkerRequest.php new file mode 100644 index 0000000..fd46935 --- /dev/null +++ b/app/Containers/Dashboard/UI/WEB/Requests/AttachDivisionWorkerRequest.php @@ -0,0 +1,35 @@ + ['required', 'exists:users,id'], + 'administrativePosition' => ['required', 'string', 'max:255'], + 'service_email' => ['nullable', 'email', 'max:255'], + 'service_phone' => ['nullable', 'string', 'max:20'], + 'cabinet' => ['nullable', 'string', 'max:10'], + ]; + } + + public function messages(): array + { + return [ + 'user_id.required' => 'Необходимо выбрать сотрудника', + 'user_id.exists' => 'Выбранный сотрудник не существует', + 'administrativePosition.required' => 'Административная должность обязательна для заполнения', + 'administrativePosition.max' => 'Должность не должна превышать 255 символов', + 'service_email.email' => 'Некорректный email адрес', + ]; + } +} diff --git a/app/Containers/Dashboard/UI/WEB/Requests/AttachFacultyWorkerRequest.php b/app/Containers/Dashboard/UI/WEB/Requests/AttachFacultyWorkerRequest.php new file mode 100644 index 0000000..3068db3 --- /dev/null +++ b/app/Containers/Dashboard/UI/WEB/Requests/AttachFacultyWorkerRequest.php @@ -0,0 +1,35 @@ + ['required', 'exists:users,id'], + 'position' => ['required', 'string', 'max:255'], + 'service_email' => ['nullable', 'email', 'max:255'], + 'service_phone' => ['nullable', 'string', 'max:20'], + 'cabinet' => ['nullable', 'string', 'max:10'], + ]; + } + + public function messages(): array + { + return [ + 'user_id.required' => 'Необходимо выбрать сотрудника', + 'user_id.exists' => 'Выбранный сотрудник не существует', + 'position.required' => 'Должность обязательна для заполнения', + 'position.max' => 'Должность не должна превышать 255 символов', + 'service_email.email' => 'Некорректный email адрес', + ]; + } +} diff --git a/app/Containers/Dashboard/UI/WEB/Requests/StoreAcademicJournalRequest.php b/app/Containers/Dashboard/UI/WEB/Requests/StoreAcademicJournalRequest.php new file mode 100644 index 0000000..4c36c33 --- /dev/null +++ b/app/Containers/Dashboard/UI/WEB/Requests/StoreAcademicJournalRequest.php @@ -0,0 +1,102 @@ + ['required', 'string', 'max:255'], + 'slug' => ['required', 'string', 'max:255', 'unique:academic_journals,slug'], + 'main_info' => ['nullable', 'array'], + 'chief_editor' => ['nullable', 'array'], + 'editors' => ['nullable', 'array'], + 'for_authors' => ['nullable', 'array'], + ]; + } + + public function messages(): array + { + return [ + 'title.required' => 'Название журнала обязательно для заполнения', + 'title.max' => 'Название журнала не должно превышать 255 символов', + 'slug.required' => 'URL-адрес обязателен для заполнения', + 'slug.unique' => 'Журнал с таким URL-адресом уже существует', + ]; + } + + protected function prepareForValidation(): void + { + // Автоматическая генерация slug если не передан + if (empty($this->slug) && !empty($this->title)) { + $this->merge([ + 'slug' => Str::slug($this->title), + ]); + } + + // Генерация search_data из main_info + if (!empty($this->main_info)) { + $this->merge([ + 'search_data' => $this->generateSearchData($this->main_info), + ]); + } + } + + private function generateSearchData(array $data): string + { + $parts = []; + foreach ($data as $block) { + $parts[] = $this->getDataFromBlocks($block); + } + $result = implode(' ', $parts); + $result = preg_replace('/\s+/', ' ', $result); + $result = trim($result); + + return strtolower($result); + } + + private function getDataFromBlocks($block): string + { + $data = ''; + switch ($block['type']) { + case 'paragraph': + $data .= strip_tags($block['data']['content']) . ' '; + break; + case 'heading': + $data .= strip_tags($block['data']['content']) . ' '; + break; + case 'files': + foreach ($block['data']['file'] as $file) { + $data .= $file['title'] . ' '; + } + break; + case 'person': + $data .= $block['data']['name'] . ' '; + break; + case 'stepper': + $data .= $block['data']['step_name'] . ' '; + foreach ($block['data']['steps'] as $step) { + $data .= $step['title'] . ' '; + $data .= strip_tags($step['content']) . ' '; + } + break; + case 'tabs': + foreach ($block['data']['tab'] as $item) { + foreach ($item['content'] as $blockItem) { + $data .= $this->getDataFromBlocks($blockItem); + } + } + break; + } + return $data; + } +} diff --git a/app/Containers/Dashboard/UI/WEB/Requests/StoreAdditionalEducationRequest.php b/app/Containers/Dashboard/UI/WEB/Requests/StoreAdditionalEducationRequest.php new file mode 100644 index 0000000..3efcac4 --- /dev/null +++ b/app/Containers/Dashboard/UI/WEB/Requests/StoreAdditionalEducationRequest.php @@ -0,0 +1,45 @@ + ['required', 'string', 'max:255'], + 'slug' => ['required', 'string', 'max:255', 'unique:additional_educations,slug'], + 'category_id' => ['required', 'exists:additional_education_categories,id'], + 'target_group' => ['required', 'string', 'max:255'], + 'qualification' => ['nullable', 'string', 'max:255'], + 'price' => ['required', 'numeric', 'min:0'], + 'learning_time' => ['required', 'integer', 'min:1'], + 'form_education' => ['required', 'integer', 'in:1,2,3'], + 'is_active' => ['boolean'], + 'content' => ['nullable', 'array'], + ]; + } + + public function attributes(): array + { + return [ + 'title' => 'название программы', + 'slug' => 'URL-адрес', + 'category_id' => 'категория', + 'target_group' => 'целевая аудитория', + 'qualification' => 'выдаваемый документ', + 'price' => 'стоимость', + 'learning_time' => 'объем (часов)', + 'form_education' => 'форма обучения', + 'is_active' => 'статус активности', + 'content' => 'содержание программы', + ]; + } +} diff --git a/app/Containers/Dashboard/UI/WEB/Requests/StoreAdmissionCampaignRequest.php b/app/Containers/Dashboard/UI/WEB/Requests/StoreAdmissionCampaignRequest.php new file mode 100644 index 0000000..865f7c9 --- /dev/null +++ b/app/Containers/Dashboard/UI/WEB/Requests/StoreAdmissionCampaignRequest.php @@ -0,0 +1,39 @@ + ['required', 'string', 'max:255'], + 'academic_year' => ['required', 'string', 'max:20'], + 'status' => ['required', 'integer', 'in:1,2,3'], + 'info' => ['nullable', 'array'], + 'info.*.edu_name' => ['required_with:info', 'integer'], + 'info.*.total_programs' => ['required_with:info', 'integer', 'min:0'], + 'info.*.och_count' => ['required_with:info', 'integer', 'min:0'], + 'info.*.zaoch_count' => ['required_with:info', 'integer', 'min:0'], + 'info.*.budget_places' => ['required_with:info', 'integer', 'min:0'], + 'info.*.non_budget_places' => ['required_with:info', 'integer', 'min:0'], + ]; + } + + public function attributes(): array + { + return [ + 'name' => 'название кампании', + 'academic_year' => 'академический год', + 'status' => 'статус кампании', + 'info' => 'информация о наборе', + ]; + } +} diff --git a/app/Containers/Dashboard/UI/WEB/Requests/StoreAdmissionPlanRequest.php b/app/Containers/Dashboard/UI/WEB/Requests/StoreAdmissionPlanRequest.php new file mode 100644 index 0000000..5ff7176 --- /dev/null +++ b/app/Containers/Dashboard/UI/WEB/Requests/StoreAdmissionPlanRequest.php @@ -0,0 +1,41 @@ + ['required', 'exists:educational_programs,id'], + 'admission_campaigns_id' => ['required', 'exists:admission_campaigns,id'], + 'exams' => ['nullable', 'array'], + 'exams.*.title' => ['required_with:exams', 'string', 'max:100'], + 'exams.*.priority' => ['required_with:exams', 'integer', 'min:0'], + 'exams.*.types' => ['nullable', 'array'], + 'exams.*.types.*.type' => ['required_with:exams.*.types', 'integer'], + 'exams.*.types.*.min_ball' => ['required_with:exams.*.types', 'integer', 'min:0', 'max:100'], + 'contests' => ['nullable', 'array'], + 'contests.*.form_education' => ['required_with:contests', 'integer'], + 'contests.*.places.form_budget' => ['required_with:contests', 'integer'], + 'contests.*.places.count' => ['required_with:contests', 'integer', 'min:0'], + ]; + } + + public function attributes(): array + { + return [ + 'educational_programs_id' => 'образовательная программа', + 'admission_campaigns_id' => 'приемная кампания', + 'exams' => 'вступительные испытания', + 'contests' => 'условия поступления', + ]; + } +} diff --git a/app/Containers/Dashboard/UI/WEB/Requests/StoreCategoryRequest.php b/app/Containers/Dashboard/UI/WEB/Requests/StoreCategoryRequest.php new file mode 100644 index 0000000..72dfbd2 --- /dev/null +++ b/app/Containers/Dashboard/UI/WEB/Requests/StoreCategoryRequest.php @@ -0,0 +1,33 @@ + ['required', 'string', 'max:255'], + 'slug' => ['required', 'string', 'max:255', 'unique:additional_education_categories,slug'], + 'dir_addit_educat_id' => ['required', 'exists:direction_additional_educations,id'], + 'is_active' => ['boolean'], + ]; + } + + public function attributes(): array + { + return [ + 'title' => 'название категории', + 'slug' => 'URL-идентификатор', + 'dir_addit_educat_id' => 'направление ДПО', + 'is_active' => 'статус активности', + ]; + } +} diff --git a/app/Containers/Dashboard/UI/WEB/Requests/StoreContactWidgetRequest.php b/app/Containers/Dashboard/UI/WEB/Requests/StoreContactWidgetRequest.php new file mode 100644 index 0000000..48ba44a --- /dev/null +++ b/app/Containers/Dashboard/UI/WEB/Requests/StoreContactWidgetRequest.php @@ -0,0 +1,47 @@ + ['required', 'string', 'max:255'], + 'slug' => ['required', 'string', 'max:255', Rule::unique('contact_widgets', 'slug')], + 'is_active' => ['boolean'], + 'content' => ['required', 'array', 'min:1'], + 'content.*.title' => ['required', 'string', 'max:255'], + 'content.*.items' => ['required', 'array', 'min:1'], + 'content.*.items.*.header' => ['required', 'string', 'max:255'], + 'content.*.items.*.details' => ['nullable', 'array'], + 'content.*.items.*.details.*.content' => ['required', 'string'], + 'content.*.items.*.details.*.url' => ['nullable', 'url'], + ]; + } + + public function messages(): array + { + return [ + 'title.required' => 'Название ресурса обязательно для заполнения', + 'title.max' => 'Название не должно превышать 255 символов', + 'slug.required' => 'URL-адрес обязателен', + 'slug.unique' => 'Такой URL-адрес уже используется', + 'slug.max' => 'URL-адрес не должен превышать 255 символов', + 'content.required' => 'Содержание обязательно для заполнения', + 'content.*.title.required' => 'Заголовок столбца обязателен', + 'content.*.items.required' => 'Добавьте хотя бы один контактный блок', + 'content.*.items.*.header.required' => 'Заголовок контакта обязателен', + 'content.*.items.*.details.*.content.required' => 'Значение контакта обязательно', + 'content.*.items.*.details.*.url.url' => 'Укажите корректный URL', + ]; + } +} diff --git a/app/Containers/Dashboard/UI/WEB/Requests/StoreCustomFormRequest.php b/app/Containers/Dashboard/UI/WEB/Requests/StoreCustomFormRequest.php new file mode 100644 index 0000000..a4d22f6 --- /dev/null +++ b/app/Containers/Dashboard/UI/WEB/Requests/StoreCustomFormRequest.php @@ -0,0 +1,47 @@ + ['required', 'string', 'max:255'], + 'form_id' => ['required', 'string', 'max:255', Rule::unique('custom_forms', 'form_id')], + 'description' => ['required', 'string', 'max:2000'], + 'status' => ['required', Rule::in(['published', 'hidden'])], + 'button' => ['required', 'string', 'max:255'], + 'send_message' => ['required', 'string', 'max:1000'], + 'columns' => ['nullable', 'array'], + 'settings' => ['nullable', 'array'], + 'settings.personal_data' => ['nullable', 'boolean'], + 'settings.captcha' => ['nullable', 'boolean'], + 'settings.period' => ['nullable', 'array'], + 'mail_settings' => ['nullable', 'array'], + ]; + } + + public function messages(): array + { + return [ + 'title.required' => 'Название формы обязательно', + 'title.max' => 'Название не должно превышать 255 символов', + 'form_id.required' => 'Уникальный ID формы обязателен', + 'form_id.unique' => 'Такой ID формы уже существует', + 'description.required' => 'Описание обязательно', + 'status.required' => 'Статус обязателен', + 'status.in' => 'Статус должен быть published или hidden', + 'button.required' => 'Текст кнопки обязателен', + 'send_message.required' => 'Сообщение после отправки обязательно', + ]; + } +} diff --git a/app/Containers/Dashboard/UI/WEB/Requests/StoreDepartmentRequest.php b/app/Containers/Dashboard/UI/WEB/Requests/StoreDepartmentRequest.php new file mode 100644 index 0000000..24d2e2b --- /dev/null +++ b/app/Containers/Dashboard/UI/WEB/Requests/StoreDepartmentRequest.php @@ -0,0 +1,37 @@ + ['required', 'string', 'max:255'], + 'slug' => ['required', 'string', 'max:255', Rule::unique('departments')], + 'faculty_id' => ['required', 'exists:faculties,id'], + 'is_active' => ['boolean'], + 'content' => ['nullable', 'array'], + ]; + } + + public function messages(): array + { + return [ + 'title.required' => 'Название кафедры обязательно для заполнения', + 'title.max' => 'Название не должно превышать 255 символов', + 'slug.required' => 'URL-адрес обязателен для заполнения', + 'slug.unique' => 'Такой URL уже используется', + 'faculty_id.required' => 'Необходимо выбрать факультет', + 'faculty_id.exists' => 'Выбранный факультет не существует', + ]; + } +} diff --git a/app/Containers/Dashboard/UI/WEB/Requests/StoreDirectionRequest.php b/app/Containers/Dashboard/UI/WEB/Requests/StoreDirectionRequest.php new file mode 100644 index 0000000..e51f567 --- /dev/null +++ b/app/Containers/Dashboard/UI/WEB/Requests/StoreDirectionRequest.php @@ -0,0 +1,31 @@ + ['required', 'string', 'max:255'], + 'slug' => ['required', 'string', 'max:255', 'unique:direction_additional_educations,slug'], + 'is_active' => ['boolean'], + ]; + } + + public function attributes(): array + { + return [ + 'title' => 'название направления', + 'slug' => 'URL-идентификатор', + 'is_active' => 'статус активности', + ]; + } +} diff --git a/app/Containers/Dashboard/UI/WEB/Requests/StoreDirectionStudyRequest.php b/app/Containers/Dashboard/UI/WEB/Requests/StoreDirectionStudyRequest.php new file mode 100644 index 0000000..f620d1d --- /dev/null +++ b/app/Containers/Dashboard/UI/WEB/Requests/StoreDirectionStudyRequest.php @@ -0,0 +1,37 @@ + ['required', 'string', 'max:255'], + 'uuid' => ['required', 'string', 'max:255', 'unique:direction_studies,uuid'], + 'slug' => ['required', 'string', 'max:255', 'unique:direction_studies,slug'], + 'code' => ['required', 'string', 'max:50'], + 'lvl_edu' => ['required', 'integer'], + 'info' => ['nullable', 'array'], + ]; + } + + public function attributes(): array + { + return [ + 'name' => 'название направления', + 'uuid' => 'UUID', + 'slug' => 'URL-идентификатор', + 'code' => 'код направления', + 'lvl_edu' => 'уровень образования', + 'info' => 'информация о направлении', + ]; + } +} diff --git a/app/Containers/Dashboard/UI/WEB/Requests/StoreDivisionRequest.php b/app/Containers/Dashboard/UI/WEB/Requests/StoreDivisionRequest.php new file mode 100644 index 0000000..160cd7a --- /dev/null +++ b/app/Containers/Dashboard/UI/WEB/Requests/StoreDivisionRequest.php @@ -0,0 +1,34 @@ + ['required', 'string', 'max:255'], + 'slug' => ['required', 'string', 'max:255', Rule::unique('divisions')->ignore($this->division)], + 'is_active' => ['boolean'], + 'description' => ['nullable', 'array'], + ]; + } + + public function messages(): array + { + return [ + 'title.required' => 'Название подразделения обязательно для заполнения', + 'title.max' => 'Название не должно превышать 255 символов', + 'slug.required' => 'URL-адрес обязателен для заполнения', + 'slug.unique' => 'Такой URL уже используется', + ]; + } +} diff --git a/app/Containers/Dashboard/UI/WEB/Requests/StoreEducationalGroupRequest.php b/app/Containers/Dashboard/UI/WEB/Requests/StoreEducationalGroupRequest.php new file mode 100644 index 0000000..9a42144 --- /dev/null +++ b/app/Containers/Dashboard/UI/WEB/Requests/StoreEducationalGroupRequest.php @@ -0,0 +1,32 @@ + ['required', 'string', 'max:50', 'unique:educational_groups,title'], + 'faculty_id' => ['required', 'exists:faculties,id'], + 'education_form_id' => ['required', 'in:1,2,3'], + ]; + } + + public function messages(): array + { + return [ + 'title.required' => 'Название группы обязательно для заполнения', + 'title.unique' => 'Группа с таким названием уже существует', + 'faculty_id.required' => 'Необходимо выбрать факультет', + 'education_form_id.required' => 'Необходимо выбрать форму обучения', + ]; + } +} diff --git a/app/Containers/Dashboard/UI/WEB/Requests/StoreEducationalProgramRequest.php b/app/Containers/Dashboard/UI/WEB/Requests/StoreEducationalProgramRequest.php new file mode 100644 index 0000000..916c158 --- /dev/null +++ b/app/Containers/Dashboard/UI/WEB/Requests/StoreEducationalProgramRequest.php @@ -0,0 +1,39 @@ + ['required', 'string', 'max:255'], + 'lvl_edu' => ['required', 'integer'], + 'status' => ['required', 'integer', 'in:1,2,3,4,5,6'], + 'lang_stud' => ['required', 'string', 'max:255'], + 'direction_study_id' => ['nullable', 'exists:direction_studies,id'], + 'about_program' => ['nullable', 'array'], + 'program_features' => ['nullable', 'array'], + ]; + } + + public function attributes(): array + { + return [ + 'name' => 'название программы', + 'lvl_edu' => 'уровень образования', + 'status' => 'статус программы', + 'lang_stud' => 'язык обучения', + 'direction_study_id' => 'направление подготовки', + 'about_program' => 'описание программы', + 'program_features' => 'особенности программы', + ]; + } +} diff --git a/app/Containers/Dashboard/UI/WEB/Requests/StoreFacultyRequest.php b/app/Containers/Dashboard/UI/WEB/Requests/StoreFacultyRequest.php new file mode 100644 index 0000000..604006a --- /dev/null +++ b/app/Containers/Dashboard/UI/WEB/Requests/StoreFacultyRequest.php @@ -0,0 +1,37 @@ + ['required', 'string', 'max:255'], + 'slug' => ['required', 'string', 'max:255', Rule::unique('faculties')->ignore($this->faculty)], + 'abbreviation' => ['required', 'string', 'max:10'], + 'is_active' => ['boolean'], + 'content' => ['nullable', 'array'], + ]; + } + + public function messages(): array + { + return [ + 'title.required' => 'Название факультета обязательно для заполнения', + 'title.max' => 'Название не должно превышать 255 символов', + 'slug.required' => 'URL-адрес обязателен для заполнения', + 'slug.unique' => 'Такой URL уже используется', + 'abbreviation.required' => 'Аббревиатура обязательна', + 'abbreviation.max' => 'Аббревиатура не должна превышать 10 символов', + ]; + } +} diff --git a/app/Containers/Dashboard/UI/WEB/Requests/StoreJournalIssueRequest.php b/app/Containers/Dashboard/UI/WEB/Requests/StoreJournalIssueRequest.php new file mode 100644 index 0000000..0776255 --- /dev/null +++ b/app/Containers/Dashboard/UI/WEB/Requests/StoreJournalIssueRequest.php @@ -0,0 +1,52 @@ + ['required', 'exists:academic_journals,id'], + 'title' => ['required', 'string', 'max:255'], + 'path_file' => ['required', 'string'], + 'year_publication' => [ + 'required', + 'integer', + 'min:1900', + 'max:' . (now()->year + 1), + ], + 'is_active' => ['nullable', 'boolean'], + 'sort' => ['nullable', 'integer'], + ]; + } + + public function messages(): array + { + return [ + 'title.required' => 'Название выпуска обязательно для заполнения', + 'title.max' => 'Название выпуска не должно превышать 255 символов', + 'path_file.required' => 'Файл выпуска обязателен', + 'year_publication.required' => 'Год публикации обязателен', + 'year_publication.min' => 'Год должен быть не ранее 1900', + 'year_publication.max' => 'Год не может быть больше ' . (now()->year + 1), + ]; + } + + protected function prepareForValidation(): void + { + // Значение по умолчанию для is_active + if (!isset($this->is_active)) { + $this->merge([ + 'is_active' => true, + ]); + } + } +} diff --git a/app/Containers/Dashboard/UI/WEB/Requests/StoreMainSectionRequest.php b/app/Containers/Dashboard/UI/WEB/Requests/StoreMainSectionRequest.php new file mode 100644 index 0000000..a5a8e15 --- /dev/null +++ b/app/Containers/Dashboard/UI/WEB/Requests/StoreMainSectionRequest.php @@ -0,0 +1,16 @@ + ['required', 'string', 'max:255'], + 'slug' => ['required', 'string', 'unique:main_sections,slug', 'max:255'], + ]; + } +} diff --git a/app/Containers/Dashboard/UI/WEB/Requests/StoreNewsCategoryRequest.php b/app/Containers/Dashboard/UI/WEB/Requests/StoreNewsCategoryRequest.php new file mode 100644 index 0000000..d79432c --- /dev/null +++ b/app/Containers/Dashboard/UI/WEB/Requests/StoreNewsCategoryRequest.php @@ -0,0 +1,29 @@ + ['required', 'string', 'max:255'], + 'is_active' => ['boolean'], + ]; + } + + public function messages(): array + { + return [ + 'title.required' => 'Название категории обязательно для заполнения', + 'title.max' => 'Название не должно превышать 255 символов', + ]; + } +} diff --git a/app/Containers/Dashboard/UI/WEB/Requests/StorePageReferenceListRequest.php b/app/Containers/Dashboard/UI/WEB/Requests/StorePageReferenceListRequest.php new file mode 100644 index 0000000..39e77a6 --- /dev/null +++ b/app/Containers/Dashboard/UI/WEB/Requests/StorePageReferenceListRequest.php @@ -0,0 +1,43 @@ + ['required', 'string', 'max:255'], + 'slug' => ['required', 'string', 'max:255', Rule::unique('page_reference_lists', 'slug')], + 'is_active' => ['boolean'], + 'content' => ['required', 'array', 'min:1'], + 'content.*.title' => ['required', 'string', 'max:255'], + 'content.*.link' => ['required', 'string', 'max:255'], + 'content.*.link_text' => ['required', 'string', 'max:50'], + 'content.*.image' => ['nullable', 'string'], + 'content.*.icon' => ['nullable', 'string'], + ]; + } + + public function messages(): array + { + return [ + 'title.required' => 'Название обязательно', + 'slug.required' => 'Slug обязателен', + 'slug.unique' => 'Такой slug уже существует', + 'content.required' => 'Добавьте хотя бы один элемент', + 'content.min' => 'Добавьте хотя бы один элемент', + 'content.*.title.required' => 'Заголовок элемента обязателен', + 'content.*.link.required' => 'Ссылка обязательна', + 'content.*.link_text.required' => 'Текст кнопки обязателен', + ]; + } +} diff --git a/app/Containers/Dashboard/UI/WEB/Requests/StorePageRequest.php b/app/Containers/Dashboard/UI/WEB/Requests/StorePageRequest.php new file mode 100644 index 0000000..503396a --- /dev/null +++ b/app/Containers/Dashboard/UI/WEB/Requests/StorePageRequest.php @@ -0,0 +1,49 @@ + ['required', 'string', 'max:255'], + 'slug' => ['required', 'string', 'max:255', Rule::unique('pages', 'slug')], + 'sub_section_id' => ['nullable', 'exists:sub_sections,id'], + 'code' => ['required', Rule::in(['200', '404', '500'])], + 'searchable' => ['boolean'], + 'icon' => ['nullable', 'string'], + 'content' => ['nullable', 'array'], + 'settings' => ['nullable', 'array'], + 'settings.hide_page_sub_section_links' => ['nullable', 'boolean'], + 'settings.hide_page_navigate_links' => ['nullable', 'boolean'], + 'settings.hide_breadcrumbs' => ['nullable', 'boolean'], + 'settings.form.id' => ['nullable', 'string'], + 'settings.form.title' => ['nullable', 'string'], + 'settings.form.description' => ['nullable', 'string'], + 'settings.form.button' => ['nullable', 'string'], + ]; + } + + public function messages(): array + { + return [ + 'title.required' => 'Заголовок обязателен для заполнения', + 'title.max' => 'Заголовок не должен превышать 255 символов', + 'slug.required' => 'URL-адрес обязателен', + 'slug.unique' => 'Такой URL-адрес уже используется', + 'slug.max' => 'URL-адрес не должен превышать 255 символов', + 'sub_section_id.exists' => 'Выбранный подраздел не существует', + 'code.required' => 'Код страницы обязателен', + 'code.in' => 'Код страницы должен быть 200, 404 или 500', + ]; + } +} diff --git a/app/Containers/Dashboard/UI/WEB/Requests/StorePostRequest.php b/app/Containers/Dashboard/UI/WEB/Requests/StorePostRequest.php new file mode 100644 index 0000000..c994f3b --- /dev/null +++ b/app/Containers/Dashboard/UI/WEB/Requests/StorePostRequest.php @@ -0,0 +1,68 @@ + ['required', 'string', 'max:255'], + 'slug' => ['required', 'string', 'max:255', Rule::unique('posts', 'slug')->ignore($this->route('post'))], + 'status' => ['required', 'integer', Rule::in([0, 1, 2, 3])], // PostStatus enum values + 'category_id' => ['nullable', 'integer', 'exists:categories,id'], + 'tags' => ['nullable', 'array'], + 'authors' => ['nullable', 'array'], + 'content' => ['required', 'array'], + 'preview' => ['nullable', 'string'], + 'images' => ['nullable', 'array'], + 'publish_setting' => ['nullable', 'array'], + 'publish_setting.publish_after' => ['nullable', 'boolean'], + 'publish_setting.publish_at' => ['nullable', 'date', 'after:now'], + 'publication' => ['nullable', 'array'], + 'publication.vk' => ['nullable', 'boolean'], + 'publication.telegram' => ['nullable', 'boolean'], + 'is_slider_enabled' => ['nullable', 'boolean'], + 'slide' => ['nullable', 'array'], + 'slide.slider_id' => ['nullable', 'integer', 'exists:sliders,id'], + 'slide.title' => ['nullable', 'string', 'max:100'], + 'slide.content' => ['nullable', 'string', 'max:255'], + 'slide.color_theme' => ['nullable', 'string'], + 'slide.image' => ['nullable', 'array'], + 'slide.image.url' => ['nullable', 'string'], + 'slide.image.shading' => ['nullable', 'string'], + 'slide.settings' => ['nullable', 'array'], + 'slide.settings.text_position' => ['nullable', 'string', 'in:left,center,right'], + 'slide.settings.link_text' => ['nullable', 'string', 'max:20'], + 'slide.end_time' => ['nullable', 'date'], + ]; + } + + public function messages(): array + { + return [ + 'title.required' => 'Заголовок обязателен для заполнения', + 'title.max' => 'Заголовок не должен превышать 255 символов', + 'slug.required' => 'URL-адрес обязателен', + 'slug.unique' => 'Такой URL-адрес уже используется', + 'status.required' => 'Статус публикации обязателен', + 'status.in' => 'Некорректный статус публикации', + 'category_id.exists' => 'Выбранная категория не существует', + 'content.required' => 'Содержание новости обязательно', + 'publish_setting.publish_at.after' => 'Дата публикации должна быть в будущем', + 'slide.slider_id.exists' => 'Выбранный слайдер не существует', + 'slide.title.max' => 'Заголовок слайда не должен превышать 100 символов', + 'slide.content.max' => 'Текст слайда не должен превышать 255 символов', + 'slide.settings.text_position.in' => 'Некорректная позиция текста', + 'slide.settings.link_text.max' => 'Текст кнопки не должен превышать 20 символов', + ]; + } +} diff --git a/app/Containers/Dashboard/UI/WEB/Requests/StoreScheduleRequest.php b/app/Containers/Dashboard/UI/WEB/Requests/StoreScheduleRequest.php new file mode 100644 index 0000000..8e30153 --- /dev/null +++ b/app/Containers/Dashboard/UI/WEB/Requests/StoreScheduleRequest.php @@ -0,0 +1,36 @@ + ['required', 'exists:educational_groups,id'], + 'file' => ['required', 'array', 'min:1', 'max:1'], + 'file.0.title' => ['required', 'string', 'max:255'], + 'file.0.path' => ['required', 'file', 'mimes:pdf', 'max:10000'], + ]; + } + + public function messages(): array + { + return [ + 'educational_group_id.required' => 'Необходимо выбрать учебную группу', + 'educational_group_id.exists' => 'Выбранная учебная группа не существует', + 'file.required' => 'Необходимо загрузить файл расписания', + 'file.0.title.required' => 'Необходимо указать название файла', + 'file.0.path.required' => 'Необходимо загрузить PDF файл', + 'file.0.path.mimes' => 'Файл должен быть в формате PDF', + 'file.0.path.max' => 'Размер файла не должен превышать 10MB', + ]; + } +} diff --git a/app/Containers/Dashboard/UI/WEB/Requests/StoreSlideRequest.php b/app/Containers/Dashboard/UI/WEB/Requests/StoreSlideRequest.php new file mode 100644 index 0000000..7569d80 --- /dev/null +++ b/app/Containers/Dashboard/UI/WEB/Requests/StoreSlideRequest.php @@ -0,0 +1,32 @@ + 'nullable|string|max:255', + 'content' => 'nullable|string|max:1000', + 'image' => 'required', + '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', + 'settings.active_button' => 'nullable|in:0,1,true,false', + 'color_theme' => 'required|string', + 'is_active' => 'nullable|in:0,1,true,false', + 'start_time' => 'nullable|date', + 'end_time' => 'nullable|date|after_or_equal:start_time', + ]; + } +} diff --git a/app/Containers/Dashboard/UI/WEB/Requests/StoreSliderRequest.php b/app/Containers/Dashboard/UI/WEB/Requests/StoreSliderRequest.php new file mode 100644 index 0000000..2c2b450 --- /dev/null +++ b/app/Containers/Dashboard/UI/WEB/Requests/StoreSliderRequest.php @@ -0,0 +1,22 @@ + 'required|string|max:255', + 'slug' => 'nullable|string|max:255|unique:sliders,slug', + 'is_active' => 'boolean', + ]; + } +} diff --git a/app/Containers/Dashboard/UI/WEB/Requests/StoreSubSectionRequest.php b/app/Containers/Dashboard/UI/WEB/Requests/StoreSubSectionRequest.php new file mode 100644 index 0000000..6099201 --- /dev/null +++ b/app/Containers/Dashboard/UI/WEB/Requests/StoreSubSectionRequest.php @@ -0,0 +1,17 @@ + ['required', 'string', 'max:255'], + 'slug' => ['required', 'string', 'unique:sub_sections,slug', 'max:255'], + 'main_section_id' => ['nullable', 'exists:main_sections,id'], + ]; + } +} diff --git a/app/Containers/Dashboard/UI/WEB/Requests/StoreUserDetailRequest.php b/app/Containers/Dashboard/UI/WEB/Requests/StoreUserDetailRequest.php new file mode 100644 index 0000000..9fe2940 --- /dev/null +++ b/app/Containers/Dashboard/UI/WEB/Requests/StoreUserDetailRequest.php @@ -0,0 +1,42 @@ + ['nullable', 'boolean'], + 'photo' => ['nullable', 'file', 'image', 'max:10240'], // 10MB max + 'contactEmail' => ['nullable', 'email', 'max:255'], + 'contactPhone' => ['nullable', 'string', 'max:255'], + 'academicTitle' => ['nullable', 'string', 'max:255'], + 'AcademicDegree' => ['nullable', 'string', 'max:255'], + 'workExperience' => ['nullable'], + 'education' => ['nullable'], + 'professionalRetraining' => ['nullable'], + 'professionalDevelopment' => ['nullable'], + 'awards' => ['nullable'], + 'professDisciplines' => ['nullable'], + 'attendedConferences' => ['nullable'], + 'publications' => ['nullable'], + 'participationScienceProjects' => ['nullable'], + 'other' => ['nullable'], + ]; + } + + public function messages(): array + { + return [ + 'contactEmail.email' => 'Некорректный формат email', + ]; + } +} diff --git a/app/Containers/Dashboard/UI/WEB/Requests/StoreUserRequest.php b/app/Containers/Dashboard/UI/WEB/Requests/StoreUserRequest.php new file mode 100644 index 0000000..dba1c33 --- /dev/null +++ b/app/Containers/Dashboard/UI/WEB/Requests/StoreUserRequest.php @@ -0,0 +1,39 @@ + ['required', 'string', 'max:255'], + 'email' => ['required', 'email', 'max:255', 'unique:users,email'], + 'password' => ['required', 'string', 'min:8', 'max:255'], + 'roles' => ['nullable', 'array'], + 'roles.*' => ['string', 'exists:roles,name'], + 'permissions' => ['nullable', 'array'], + 'permissions.*' => ['string', 'exists:permissions,name'], + ]; + } + + public function messages(): array + { + return [ + 'name.required' => 'ФИО обязательно для заполнения', + 'email.required' => 'Email обязателен для заполнения', + 'email.email' => 'Некорректный формат email', + 'email.unique' => 'Пользователь с таким email уже существует', + 'password.required' => 'Пароль обязателен для заполнения', + 'password.min' => 'Пароль должен содержать минимум 8 символов', + ]; + } +} diff --git a/app/Containers/Dashboard/UI/WEB/Requests/UpdateAcademicJournalRequest.php b/app/Containers/Dashboard/UI/WEB/Requests/UpdateAcademicJournalRequest.php new file mode 100644 index 0000000..2aaedcf --- /dev/null +++ b/app/Containers/Dashboard/UI/WEB/Requests/UpdateAcademicJournalRequest.php @@ -0,0 +1,107 @@ + ['required', 'string', 'max:255'], + 'slug' => [ + 'required', + 'string', + 'max:255', + 'unique:academic_journals,slug,' . $this->route('academicJournal')->id, + ], + 'main_info' => ['nullable', 'array'], + 'chief_editor' => ['nullable', 'array'], + 'editors' => ['nullable', 'array'], + 'for_authors' => ['nullable', 'array'], + ]; + } + + public function messages(): array + { + return [ + 'title.required' => 'Название журнала обязательно для заполнения', + 'title.max' => 'Название журнала не должно превышать 255 символов', + 'slug.required' => 'URL-адрес обязателен для заполнения', + 'slug.unique' => 'Журнал с таким URL-адресом уже существует', + ]; + } + + protected function prepareForValidation(): void + { + // Автоматическая генерация slug если не передан + if (empty($this->slug) && !empty($this->title)) { + $this->merge([ + 'slug' => Str::slug($this->title), + ]); + } + + // Генерация search_data из main_info + if (!empty($this->main_info)) { + $this->merge([ + 'search_data' => $this->generateSearchData($this->main_info), + ]); + } + } + + private function generateSearchData(array $data): string + { + $parts = []; + foreach ($data as $block) { + $parts[] = $this->getDataFromBlocks($block); + } + $result = implode(' ', $parts); + $result = preg_replace('/\s+/', ' ', $result); + $result = trim($result); + + return strtolower($result); + } + + private function getDataFromBlocks($block): string + { + $data = ''; + switch ($block['type']) { + case 'paragraph': + $data .= strip_tags($block['data']['content']) . ' '; + break; + case 'heading': + $data .= strip_tags($block['data']['content']) . ' '; + break; + case 'files': + foreach ($block['data']['file'] as $file) { + $data .= $file['title'] . ' '; + } + break; + case 'person': + $data .= $block['data']['name'] . ' '; + break; + case 'stepper': + $data .= $block['data']['step_name'] . ' '; + foreach ($block['data']['steps'] as $step) { + $data .= $step['title'] . ' '; + $data .= strip_tags($step['content']) . ' '; + } + break; + case 'tabs': + foreach ($block['data']['tab'] as $item) { + foreach ($item['content'] as $blockItem) { + $data .= $this->getDataFromBlocks($blockItem); + } + } + break; + } + return $data; + } +} diff --git a/app/Containers/Dashboard/UI/WEB/Requests/UpdateAdditionalEducationRequest.php b/app/Containers/Dashboard/UI/WEB/Requests/UpdateAdditionalEducationRequest.php new file mode 100644 index 0000000..a53b333 --- /dev/null +++ b/app/Containers/Dashboard/UI/WEB/Requests/UpdateAdditionalEducationRequest.php @@ -0,0 +1,48 @@ +route('additionalEducation')?->id; + + return [ + 'title' => ['required', 'string', 'max:255'], + 'slug' => ['required', 'string', 'max:255', Rule::unique('additional_educations', 'slug')->ignore($educationId)], + 'category_id' => ['required', 'exists:additional_education_categories,id'], + 'target_group' => ['required', 'string', 'max:255'], + 'qualification' => ['nullable', 'string', 'max:255'], + 'price' => ['required', 'numeric', 'min:0'], + 'learning_time' => ['required', 'integer', 'min:1'], + 'form_education' => ['required', 'integer', 'in:1,2,3'], + 'is_active' => ['boolean'], + 'content' => ['nullable', 'array'], + ]; + } + + public function attributes(): array + { + return [ + 'title' => 'название программы', + 'slug' => 'URL-адрес', + 'category_id' => 'категория', + 'target_group' => 'целевая аудитория', + 'qualification' => 'выдаваемый документ', + 'price' => 'стоимость', + 'learning_time' => 'объем (часов)', + 'form_education' => 'форма обучения', + 'is_active' => 'статус активности', + 'content' => 'содержание программы', + ]; + } +} diff --git a/app/Containers/Dashboard/UI/WEB/Requests/UpdateAdmissionCampaignRequest.php b/app/Containers/Dashboard/UI/WEB/Requests/UpdateAdmissionCampaignRequest.php new file mode 100644 index 0000000..02f528e --- /dev/null +++ b/app/Containers/Dashboard/UI/WEB/Requests/UpdateAdmissionCampaignRequest.php @@ -0,0 +1,39 @@ + ['required', 'string', 'max:255'], + 'academic_year' => ['required', 'string', 'max:20'], + 'status' => ['required', 'integer', 'in:1,2,3'], + 'info' => ['nullable', 'array'], + 'info.*.edu_name' => ['required_with:info', 'integer'], + 'info.*.total_programs' => ['required_with:info', 'integer', 'min:0'], + 'info.*.och_count' => ['required_with:info', 'integer', 'min:0'], + 'info.*.zaoch_count' => ['required_with:info', 'integer', 'min:0'], + 'info.*.budget_places' => ['required_with:info', 'integer', 'min:0'], + 'info.*.non_budget_places' => ['required_with:info', 'integer', 'min:0'], + ]; + } + + public function attributes(): array + { + return [ + 'name' => 'название кампании', + 'academic_year' => 'академический год', + 'status' => 'статус кампании', + 'info' => 'информация о наборе', + ]; + } +} diff --git a/app/Containers/Dashboard/UI/WEB/Requests/UpdateAdmissionPlanRequest.php b/app/Containers/Dashboard/UI/WEB/Requests/UpdateAdmissionPlanRequest.php new file mode 100644 index 0000000..5249273 --- /dev/null +++ b/app/Containers/Dashboard/UI/WEB/Requests/UpdateAdmissionPlanRequest.php @@ -0,0 +1,41 @@ + ['required', 'exists:educational_programs,id'], + 'admission_campaigns_id' => ['required', 'exists:admission_campaigns,id'], + 'exams' => ['nullable', 'array'], + 'exams.*.title' => ['required_with:exams', 'string', 'max:100'], + 'exams.*.priority' => ['required_with:exams', 'integer', 'min:0'], + 'exams.*.types' => ['nullable', 'array'], + 'exams.*.types.*.type' => ['required_with:exams.*.types', 'integer'], + 'exams.*.types.*.min_ball' => ['required_with:exams.*.types', 'integer', 'min:0', 'max:100'], + 'contests' => ['nullable', 'array'], + 'contests.*.form_education' => ['required_with:contests', 'integer'], + 'contests.*.places.form_budget' => ['required_with:contests', 'integer'], + 'contests.*.places.count' => ['required_with:contests', 'integer', 'min:0'], + ]; + } + + public function attributes(): array + { + return [ + 'educational_programs_id' => 'образовательная программа', + 'admission_campaigns_id' => 'приемная кампания', + 'exams' => 'вступительные испытания', + 'contests' => 'условия поступления', + ]; + } +} diff --git a/app/Containers/Dashboard/UI/WEB/Requests/UpdateCategoryRequest.php b/app/Containers/Dashboard/UI/WEB/Requests/UpdateCategoryRequest.php new file mode 100644 index 0000000..41bdf6c --- /dev/null +++ b/app/Containers/Dashboard/UI/WEB/Requests/UpdateCategoryRequest.php @@ -0,0 +1,36 @@ +route('category')?->id; + + return [ + 'title' => ['required', 'string', 'max:255'], + 'slug' => ['required', 'string', 'max:255', Rule::unique('additional_education_categories', 'slug')->ignore($categoryId)], + 'dir_addit_educat_id' => ['required', 'exists:direction_additional_educations,id'], + 'is_active' => ['boolean'], + ]; + } + + public function attributes(): array + { + return [ + 'title' => 'название категории', + 'slug' => 'URL-идентификатор', + 'dir_addit_educat_id' => 'направление ДПО', + 'is_active' => 'статус активности', + ]; + } +} diff --git a/app/Containers/Dashboard/UI/WEB/Requests/UpdateContactWidgetRequest.php b/app/Containers/Dashboard/UI/WEB/Requests/UpdateContactWidgetRequest.php new file mode 100644 index 0000000..a2a953c --- /dev/null +++ b/app/Containers/Dashboard/UI/WEB/Requests/UpdateContactWidgetRequest.php @@ -0,0 +1,49 @@ +route('contactWidget')?->id; + + return [ + 'title' => ['required', 'string', 'max:255'], + 'slug' => ['required', 'string', 'max:255', Rule::unique('contact_widgets', 'slug')->ignore($widgetId)], + 'is_active' => ['boolean'], + 'content' => ['required', 'array', 'min:1'], + 'content.*.title' => ['required', 'string', 'max:255'], + 'content.*.items' => ['required', 'array', 'min:1'], + 'content.*.items.*.header' => ['required', 'string', 'max:255'], + 'content.*.items.*.details' => ['nullable', 'array'], + 'content.*.items.*.details.*.content' => ['required', 'string'], + 'content.*.items.*.details.*.url' => ['nullable', 'url'], + ]; + } + + public function messages(): array + { + return [ + 'title.required' => 'Название ресурса обязательно для заполнения', + 'title.max' => 'Название не должно превышать 255 символов', + 'slug.required' => 'URL-адрес обязателен', + 'slug.unique' => 'Такой URL-адрес уже используется', + 'slug.max' => 'URL-адрес не должен превышать 255 символов', + 'content.required' => 'Содержание обязательно для заполнения', + 'content.*.title.required' => 'Заголовок столбца обязателен', + 'content.*.items.required' => 'Добавьте хотя бы один контактный блок', + 'content.*.items.*.header.required' => 'Заголовок контакта обязателен', + 'content.*.items.*.details.*.content.required' => 'Значение контакта обязательно', + 'content.*.items.*.details.*.url.url' => 'Укажите корректный URL', + ]; + } +} diff --git a/app/Containers/Dashboard/UI/WEB/Requests/UpdateCustomFormRequest.php b/app/Containers/Dashboard/UI/WEB/Requests/UpdateCustomFormRequest.php new file mode 100644 index 0000000..14fb6da --- /dev/null +++ b/app/Containers/Dashboard/UI/WEB/Requests/UpdateCustomFormRequest.php @@ -0,0 +1,49 @@ +route('customForm')?->id; + + return [ + 'title' => ['required', 'string', 'max:255'], + 'form_id' => ['required', 'string', 'max:255', Rule::unique('custom_forms', 'form_id')->ignore($formId)], + 'description' => ['required', 'string', 'max:2000'], + 'status' => ['required', Rule::in(['published', 'hidden'])], + 'button' => ['required', 'string', 'max:255'], + 'send_message' => ['required', 'string', 'max:1000'], + 'columns' => ['nullable', 'array'], + 'settings' => ['nullable', 'array'], + 'settings.personal_data' => ['nullable', 'boolean'], + 'settings.captcha' => ['nullable', 'boolean'], + 'settings.period' => ['nullable', 'array'], + 'mail_settings' => ['nullable', 'array'], + ]; + } + + public function messages(): array + { + return [ + 'title.required' => 'Название формы обязательно', + 'title.max' => 'Название не должно превышать 255 символов', + 'form_id.required' => 'Уникальный ID формы обязателен', + 'form_id.unique' => 'Такой ID формы уже существует', + 'description.required' => 'Описание обязательно', + 'status.required' => 'Статус обязателен', + 'status.in' => 'Статус должен быть published или hidden', + 'button.required' => 'Текст кнопки обязателен', + 'send_message.required' => 'Сообщение после отправки обязательно', + ]; + } +} diff --git a/app/Containers/Dashboard/UI/WEB/Requests/UpdateDepartmentRequest.php b/app/Containers/Dashboard/UI/WEB/Requests/UpdateDepartmentRequest.php new file mode 100644 index 0000000..e155032 --- /dev/null +++ b/app/Containers/Dashboard/UI/WEB/Requests/UpdateDepartmentRequest.php @@ -0,0 +1,37 @@ + ['required', 'string', 'max:255'], + 'slug' => ['required', 'string', 'max:255', Rule::unique('departments')->ignore($this->department)], + 'faculty_id' => ['required', 'exists:faculties,id'], + 'is_active' => ['boolean'], + 'content' => ['nullable', 'array'], + ]; + } + + public function messages(): array + { + return [ + 'title.required' => 'Название кафедры обязательно для заполнения', + 'title.max' => 'Название не должно превышать 255 символов', + 'slug.required' => 'URL-адрес обязателен для заполнения', + 'slug.unique' => 'Такой URL уже используется', + 'faculty_id.required' => 'Необходимо выбрать факультет', + 'faculty_id.exists' => 'Выбранный факультет не существует', + ]; + } +} diff --git a/app/Containers/Dashboard/UI/WEB/Requests/UpdateDirectionRequest.php b/app/Containers/Dashboard/UI/WEB/Requests/UpdateDirectionRequest.php new file mode 100644 index 0000000..963c1cd --- /dev/null +++ b/app/Containers/Dashboard/UI/WEB/Requests/UpdateDirectionRequest.php @@ -0,0 +1,34 @@ +route('direction')?->id; + + return [ + 'title' => ['required', 'string', 'max:255'], + 'slug' => ['required', 'string', 'max:255', Rule::unique('direction_additional_educations', 'slug')->ignore($directionId)], + 'is_active' => ['boolean'], + ]; + } + + public function attributes(): array + { + return [ + 'title' => 'название направления', + 'slug' => 'URL-идентификатор', + 'is_active' => 'статус активности', + ]; + } +} diff --git a/app/Containers/Dashboard/UI/WEB/Requests/UpdateDirectionStudyRequest.php b/app/Containers/Dashboard/UI/WEB/Requests/UpdateDirectionStudyRequest.php new file mode 100644 index 0000000..f95bac7 --- /dev/null +++ b/app/Containers/Dashboard/UI/WEB/Requests/UpdateDirectionStudyRequest.php @@ -0,0 +1,40 @@ +route('directionStudy')?->id; + + return [ + 'name' => ['required', 'string', 'max:255'], + 'uuid' => ['required', 'string', 'max:255', Rule::unique('direction_studies', 'uuid')->ignore($directionId)], + 'slug' => ['required', 'string', 'max:255', Rule::unique('direction_studies', 'slug')->ignore($directionId)], + 'code' => ['required', 'string', 'max:50'], + 'lvl_edu' => ['required', 'integer'], + 'info' => ['nullable', 'array'], + ]; + } + + public function attributes(): array + { + return [ + 'name' => 'название направления', + 'uuid' => 'UUID', + 'slug' => 'URL-идентификатор', + 'code' => 'код направления', + 'lvl_edu' => 'уровень образования', + 'info' => 'информация о направлении', + ]; + } +} diff --git a/app/Containers/Dashboard/UI/WEB/Requests/UpdateDivisionRequest.php b/app/Containers/Dashboard/UI/WEB/Requests/UpdateDivisionRequest.php new file mode 100644 index 0000000..98d1836 --- /dev/null +++ b/app/Containers/Dashboard/UI/WEB/Requests/UpdateDivisionRequest.php @@ -0,0 +1,34 @@ + ['required', 'string', 'max:255'], + 'slug' => ['required', 'string', 'max:255', Rule::unique('divisions')->ignore($this->division)], + 'is_active' => ['boolean'], + 'description' => ['nullable', 'array'], + ]; + } + + public function messages(): array + { + return [ + 'title.required' => 'Название подразделения обязательно для заполнения', + 'title.max' => 'Название не должно превышать 255 символов', + 'slug.required' => 'URL-адрес обязателен для заполнения', + 'slug.unique' => 'Такой URL уже используется', + ]; + } +} diff --git a/app/Containers/Dashboard/UI/WEB/Requests/UpdateEducationalGroupRequest.php b/app/Containers/Dashboard/UI/WEB/Requests/UpdateEducationalGroupRequest.php new file mode 100644 index 0000000..a242ae4 --- /dev/null +++ b/app/Containers/Dashboard/UI/WEB/Requests/UpdateEducationalGroupRequest.php @@ -0,0 +1,34 @@ +route('educationalGroup')->id; + + return [ + 'title' => ['required', 'string', 'max:50', 'unique:educational_groups,title,' . $groupId], + 'faculty_id' => ['required', 'exists:faculties,id'], + 'education_form_id' => ['required', 'in:1,2,3'], + ]; + } + + public function messages(): array + { + return [ + 'title.required' => 'Название группы обязательно для заполнения', + 'title.unique' => 'Группа с таким названием уже существует', + 'faculty_id.required' => 'Необходимо выбрать факультет', + 'education_form_id.required' => 'Необходимо выбрать форму обучения', + ]; + } +} diff --git a/app/Containers/Dashboard/UI/WEB/Requests/UpdateEducationalProgramRequest.php b/app/Containers/Dashboard/UI/WEB/Requests/UpdateEducationalProgramRequest.php new file mode 100644 index 0000000..f45729a --- /dev/null +++ b/app/Containers/Dashboard/UI/WEB/Requests/UpdateEducationalProgramRequest.php @@ -0,0 +1,42 @@ +route('educationalProgram')?->id; + + return [ + 'name' => ['required', 'string', 'max:255'], + 'lvl_edu' => ['required', 'integer'], + 'status' => ['required', 'integer', 'in:1,2,3,4,5,6'], + 'lang_stud' => ['required', 'string', 'max:255'], + 'direction_study_id' => ['nullable', 'exists:direction_studies,id'], + 'about_program' => ['nullable', 'array'], + 'program_features' => ['nullable', 'array'], + ]; + } + + public function attributes(): array + { + return [ + 'name' => 'название программы', + 'lvl_edu' => 'уровень образования', + 'status' => 'статус программы', + 'lang_stud' => 'язык обучения', + 'direction_study_id' => 'направление подготовки', + 'about_program' => 'описание программы', + 'program_features' => 'особенности программы', + ]; + } +} diff --git a/app/Containers/Dashboard/UI/WEB/Requests/UpdateFacultyRequest.php b/app/Containers/Dashboard/UI/WEB/Requests/UpdateFacultyRequest.php new file mode 100644 index 0000000..7cb42cd --- /dev/null +++ b/app/Containers/Dashboard/UI/WEB/Requests/UpdateFacultyRequest.php @@ -0,0 +1,37 @@ + ['required', 'string', 'max:255'], + 'slug' => ['required', 'string', 'max:255', Rule::unique('faculties')->ignore($this->faculty)], + 'abbreviation' => ['required', 'string', 'max:10'], + 'is_active' => ['boolean'], + 'content' => ['nullable', 'array'], + ]; + } + + public function messages(): array + { + return [ + 'title.required' => 'Название факультета обязательно для заполнения', + 'title.max' => 'Название не должно превышать 255 символов', + 'slug.required' => 'URL-адрес обязателен для заполнения', + 'slug.unique' => 'Такой URL уже используется', + 'abbreviation.required' => 'Аббревиатура обязательна', + 'abbreviation.max' => 'Аббревиатура не должна превышать 10 символов', + ]; + } +} diff --git a/app/Containers/Dashboard/UI/WEB/Requests/UpdateJournalIssueRequest.php b/app/Containers/Dashboard/UI/WEB/Requests/UpdateJournalIssueRequest.php new file mode 100644 index 0000000..866b1ba --- /dev/null +++ b/app/Containers/Dashboard/UI/WEB/Requests/UpdateJournalIssueRequest.php @@ -0,0 +1,41 @@ + ['required', 'string', 'max:255'], + 'path_file' => ['required', 'string'], + 'year_publication' => [ + 'required', + 'integer', + 'min:1900', + 'max:' . (now()->year + 1), + ], + 'is_active' => ['nullable', 'boolean'], + 'sort' => ['nullable', 'integer'], + ]; + } + + public function messages(): array + { + return [ + 'title.required' => 'Название выпуска обязательно для заполнения', + 'title.max' => 'Название выпуска не должно превышать 255 символов', + 'path_file.required' => 'Файл выпуска обязателен', + 'year_publication.required' => 'Год публикации обязателен', + 'year_publication.min' => 'Год должен быть не ранее 1900', + 'year_publication.max' => 'Год не может быть больше ' . (now()->year + 1), + ]; + } +} diff --git a/app/Containers/Dashboard/UI/WEB/Requests/UpdateMainSectionRequest.php b/app/Containers/Dashboard/UI/WEB/Requests/UpdateMainSectionRequest.php new file mode 100644 index 0000000..e9fcc3c --- /dev/null +++ b/app/Containers/Dashboard/UI/WEB/Requests/UpdateMainSectionRequest.php @@ -0,0 +1,16 @@ + ['required', 'string', 'max:255'], + 'slug' => ['required', 'string', 'unique:main_sections,slug,' . $this->mainSection->id, 'max:255'], + ]; + } +} diff --git a/app/Containers/Dashboard/UI/WEB/Requests/UpdateNewsCategoryRequest.php b/app/Containers/Dashboard/UI/WEB/Requests/UpdateNewsCategoryRequest.php new file mode 100644 index 0000000..76a40e7 --- /dev/null +++ b/app/Containers/Dashboard/UI/WEB/Requests/UpdateNewsCategoryRequest.php @@ -0,0 +1,32 @@ +route('category')?->id; + + return [ + 'title' => ['required', 'string', 'max:255'], + 'is_active' => ['boolean'], + ]; + } + + public function messages(): array + { + return [ + 'title.required' => 'Название категории обязательно для заполнения', + 'title.max' => 'Название не должно превышать 255 символов', + ]; + } +} diff --git a/app/Containers/Dashboard/UI/WEB/Requests/UpdatePageReferenceListRequest.php b/app/Containers/Dashboard/UI/WEB/Requests/UpdatePageReferenceListRequest.php new file mode 100644 index 0000000..174f174 --- /dev/null +++ b/app/Containers/Dashboard/UI/WEB/Requests/UpdatePageReferenceListRequest.php @@ -0,0 +1,45 @@ +route('pageReferenceList')?->id; + + return [ + 'title' => ['required', 'string', 'max:255'], + 'slug' => ['required', 'string', 'max:255', Rule::unique('page_reference_lists', 'slug')->ignore($listId)], + 'is_active' => ['boolean'], + 'content' => ['required', 'array', 'min:1'], + 'content.*.title' => ['required', 'string', 'max:255'], + 'content.*.link' => ['required', 'string', 'max:255'], + 'content.*.link_text' => ['required', 'string', 'max:50'], + 'content.*.image' => ['nullable', 'string'], + 'content.*.icon' => ['nullable', 'string'], + ]; + } + + public function messages(): array + { + return [ + 'title.required' => 'Название обязательно', + 'slug.required' => 'Slug обязателен', + 'slug.unique' => 'Такой slug уже существует', + 'content.required' => 'Добавьте хотя бы один элемент', + 'content.min' => 'Добавьте хотя бы один элемент', + 'content.*.title.required' => 'Заголовок элемента обязателен', + 'content.*.link.required' => 'Ссылка обязательна', + 'content.*.link_text.required' => 'Текст кнопки обязателен', + ]; + } +} diff --git a/app/Containers/Dashboard/UI/WEB/Requests/UpdatePageRequest.php b/app/Containers/Dashboard/UI/WEB/Requests/UpdatePageRequest.php new file mode 100644 index 0000000..32e080a --- /dev/null +++ b/app/Containers/Dashboard/UI/WEB/Requests/UpdatePageRequest.php @@ -0,0 +1,49 @@ + ['required', 'string', 'max:255'], + 'slug' => ['required', 'string', 'max:255', Rule::unique('pages', 'slug')->ignore($this->page->id)], + 'sub_section_id' => ['nullable', 'exists:sub_sections,id'], + 'code' => ['required', Rule::in(['200', '404', '500'])], + 'searchable' => ['boolean'], + 'icon' => ['nullable', 'string'], + 'content' => ['nullable', 'array'], + 'settings' => ['nullable', 'array'], + 'settings.hide_page_sub_section_links' => ['nullable', 'boolean'], + 'settings.hide_page_navigate_links' => ['nullable', 'boolean'], + 'settings.hide_breadcrumbs' => ['nullable', 'boolean'], + 'settings.form.id' => ['nullable', 'string'], + 'settings.form.title' => ['nullable', 'string'], + 'settings.form.description' => ['nullable', 'string'], + 'settings.form.button' => ['nullable', 'string'], + ]; + } + + public function messages(): array + { + return [ + 'title.required' => 'Заголовок обязателен для заполнения', + 'title.max' => 'Заголовок не должен превышать 255 символов', + 'slug.required' => 'URL-адрес обязателен', + 'slug.unique' => 'Такой URL-адрес уже используется', + 'slug.max' => 'URL-адрес не должен превышать 255 символов', + 'sub_section_id.exists' => 'Выбранный подраздел не существует', + 'code.required' => 'Код страницы обязателен', + 'code.in' => 'Код страницы должен быть 200, 404 или 500', + ]; + } +} diff --git a/app/Containers/Dashboard/UI/WEB/Requests/UpdateScheduleRequest.php b/app/Containers/Dashboard/UI/WEB/Requests/UpdateScheduleRequest.php new file mode 100644 index 0000000..e49863e --- /dev/null +++ b/app/Containers/Dashboard/UI/WEB/Requests/UpdateScheduleRequest.php @@ -0,0 +1,35 @@ + ['required', 'exists:educational_groups,id'], + 'file' => ['sometimes', 'array', 'min:1', 'max:1'], + 'file.0.title' => ['required_with:file', 'string', 'max:255'], + 'file.0.path' => ['required_with:file', 'file', 'mimes:pdf', 'max:10000'], + ]; + } + + public function messages(): array + { + return [ + 'educational_group_id.required' => 'Необходимо выбрать учебную группу', + 'educational_group_id.exists' => 'Выбранная учебная группа не существует', + 'file.0.title.required' => 'Необходимо указать название файла', + 'file.0.path.required' => 'Необходимо загрузить PDF файл', + 'file.0.path.mimes' => 'Файл должен быть в формате PDF', + 'file.0.path.max' => 'Размер файла не должен превышать 10MB', + ]; + } +} diff --git a/app/Containers/Dashboard/UI/WEB/Requests/UpdateSlideRequest.php b/app/Containers/Dashboard/UI/WEB/Requests/UpdateSlideRequest.php new file mode 100644 index 0000000..06e0836 --- /dev/null +++ b/app/Containers/Dashboard/UI/WEB/Requests/UpdateSlideRequest.php @@ -0,0 +1,32 @@ + 'nullable|string|max:255', + 'content' => 'nullable|string|max:1000', + 'image' => 'sometimes', + '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', + 'settings.active_button' => 'nullable|in:0,1,true,false', + 'color_theme' => 'sometimes|required|string', + 'is_active' => 'nullable|in:0,1,true,false', + 'start_time' => 'nullable|date', + 'end_time' => 'nullable|date|after_or_equal:start_time', + ]; + } +} diff --git a/app/Containers/Dashboard/UI/WEB/Requests/UpdateSliderRequest.php b/app/Containers/Dashboard/UI/WEB/Requests/UpdateSliderRequest.php new file mode 100644 index 0000000..c6bdb3a --- /dev/null +++ b/app/Containers/Dashboard/UI/WEB/Requests/UpdateSliderRequest.php @@ -0,0 +1,22 @@ + 'sometimes|required|string|max:255', + 'slug' => 'sometimes|required|string|max:255|unique:sliders,slug,' . $this->slider->id, + 'is_active' => 'sometimes|boolean', + ]; + } +} diff --git a/app/Containers/Dashboard/UI/WEB/Requests/UpdateSubSectionRequest.php b/app/Containers/Dashboard/UI/WEB/Requests/UpdateSubSectionRequest.php new file mode 100644 index 0000000..34a168a --- /dev/null +++ b/app/Containers/Dashboard/UI/WEB/Requests/UpdateSubSectionRequest.php @@ -0,0 +1,19 @@ + ['required', 'string', 'max:255'], + 'slug' => ['required', 'string', 'unique:sub_sections,slug,' . $this->subSection->id, 'max:255'], + 'main_section_id' => ['nullable', 'exists:main_sections,id'], + 'page_ids' => ['nullable', 'array'], + 'page_ids.*' => ['exists:pages,id'], + ]; + } +} diff --git a/app/Containers/Dashboard/UI/WEB/Requests/UpdateUserDetailRequest.php b/app/Containers/Dashboard/UI/WEB/Requests/UpdateUserDetailRequest.php new file mode 100644 index 0000000..daa0461 --- /dev/null +++ b/app/Containers/Dashboard/UI/WEB/Requests/UpdateUserDetailRequest.php @@ -0,0 +1,42 @@ + ['nullable', 'boolean'], + 'photo' => ['nullable', 'file', 'image', 'max:10240'], // 10MB max + 'contactEmail' => ['nullable', 'email', 'max:255'], + 'contactPhone' => ['nullable', 'string', 'max:255'], + 'academicTitle' => ['nullable', 'string', 'max:255'], + 'AcademicDegree' => ['nullable', 'string', 'max:255'], + 'workExperience' => ['nullable'], + 'education' => ['nullable'], + 'professionalRetraining' => ['nullable'], + 'professionalDevelopment' => ['nullable'], + 'awards' => ['nullable'], + 'professDisciplines' => ['nullable'], + 'attendedConferences' => ['nullable'], + 'publications' => ['nullable'], + 'participationScienceProjects' => ['nullable'], + 'other' => ['nullable'], + ]; + } + + public function messages(): array + { + return [ + 'contactEmail.email' => 'Некорректный формат email', + ]; + } +} diff --git a/app/Containers/Dashboard/UI/WEB/Requests/UpdateUserRequest.php b/app/Containers/Dashboard/UI/WEB/Requests/UpdateUserRequest.php new file mode 100644 index 0000000..7fd21f0 --- /dev/null +++ b/app/Containers/Dashboard/UI/WEB/Requests/UpdateUserRequest.php @@ -0,0 +1,40 @@ +route('user')->id; + + return [ + 'name' => ['required', 'string', 'max:255'], + 'email' => ['required', 'email', 'max:255', Rule::unique('users', 'email')->ignore($userId)], + 'password' => ['nullable', 'string', 'min:8', 'max:255'], + 'roles' => ['nullable', 'array'], + 'roles.*' => ['string', 'exists:roles,name'], + 'permissions' => ['nullable', 'array'], + 'permissions.*' => ['string', 'exists:permissions,name'], + ]; + } + + public function messages(): array + { + return [ + 'name.required' => 'ФИО обязательно для заполнения', + 'email.required' => 'Email обязателен для заполнения', + 'email.email' => 'Некорректный формат email', + 'email.unique' => 'Пользователь с таким email уже существует', + 'password.min' => 'Пароль должен содержать минимум 8 символов', + ]; + } +} diff --git a/app/Containers/Dashboard/UI/WEB/Routes/web.php b/app/Containers/Dashboard/UI/WEB/Routes/web.php index fca19e6..0fbc720 100755 --- a/app/Containers/Dashboard/UI/WEB/Routes/web.php +++ b/app/Containers/Dashboard/UI/WEB/Routes/web.php @@ -1,15 +1,208 @@ group(function () { + Route::get('/dashboard/login', [AuthenticatedSessionController::class, 'create'])->name('login'); + Route::post('/dashboard/login', [AuthenticatedSessionController::class, 'store']); +}); -Route::middleware(['access-check', 'superadmin'])->group(function () { +Route::post('/dashboard/logout', [AuthenticatedSessionController::class, 'destroy'])->name('logout'); + +// Authenticated dashboard routes +Route::middleware(['access-check', 'dashboard.auth'])->group(function () { Route::get('/dashboard', IndexDashboardController::class)->name('dashboard.index'); + // CRUD постов + Route::prefix('/dashboard/posts')->name('dashboard.posts.')->group(function () { + Route::get('/', [PostController::class, 'index'])->name('index'); + Route::get('/ai-prepared', [PostController::class, 'aiPrepared'])->name('ai-prepared'); + Route::post('/ai-prepared/parse-email', [ParseEmailNewsController::class, '__invoke'])->name('ai-prepared.parse-email'); + Route::get('/create', [PostController::class, 'create'])->name('create'); + Route::post('/', [PostController::class, 'store'])->name('store'); + Route::delete('/bulk-destroy', [PostController::class, 'bulkDestroy'])->name('bulk-destroy'); + Route::post('/bulk-publish', [PostController::class, 'bulkPublish'])->name('bulk-publish'); + Route::post('/bulk-verification', [PostController::class, 'bulkVerification'])->name('bulk-verification'); + Route::get('/{post}', [PostController::class, 'show'])->name('show'); + Route::get('/{post}/edit', [PostController::class, 'edit'])->name('edit'); + Route::put('/{post}', [PostController::class, 'update'])->name('update'); + Route::delete('/{post}', [PostController::class, 'destroy'])->name('destroy'); + }); + + // CRUD категорий новостей + Route::prefix('/dashboard/categories')->name('dashboard.categories.')->group(function () { + Route::get('/', [NewsCategoryController::class, 'index'])->name('index'); + Route::get('/create', [NewsCategoryController::class, 'create'])->name('create'); + Route::post('/', [NewsCategoryController::class, 'store'])->name('store'); + Route::get('/{category}/edit', [NewsCategoryController::class, 'edit'])->name('edit'); + Route::put('/{category}', [NewsCategoryController::class, 'update'])->name('update'); + Route::delete('/{category}', [NewsCategoryController::class, 'destroy'])->name('destroy'); + }); + + // CRUD учебных групп + Route::prefix('/dashboard/educational-groups')->name('dashboard.educational-groups.')->group(function () { + Route::get('/', [EducationalGroupController::class, 'index'])->name('index'); + Route::get('/create', [EducationalGroupController::class, 'create'])->name('create'); + Route::post('/', [EducationalGroupController::class, 'store'])->name('store'); + Route::get('/{educationalGroup}/edit', [EducationalGroupController::class, 'edit'])->name('edit'); + Route::put('/{educationalGroup}', [EducationalGroupController::class, 'update'])->name('update'); + Route::delete('/{educationalGroup}', [EducationalGroupController::class, 'destroy'])->name('destroy'); + }); + + // CRUD программ дополнительного образования + Route::prefix('/dashboard/additional-educations')->name('dashboard.additional-educations.')->group(function () { + Route::get('/', [AdditionalEducationController::class, 'index'])->name('index'); + Route::get('/create', [AdditionalEducationController::class, 'create'])->name('create'); + Route::post('/', [AdditionalEducationController::class, 'store'])->name('store'); + Route::get('/{additionalEducation}/edit', [AdditionalEducationController::class, 'edit'])->name('edit'); + Route::put('/{additionalEducation}', [AdditionalEducationController::class, 'update'])->name('update'); + Route::delete('/{additionalEducation}', [AdditionalEducationController::class, 'destroy'])->name('destroy'); + + // Направления ДПО + Route::prefix('/directions')->name('directions.')->group(function () { + Route::get('/', [DirectionController::class, 'index'])->name('index'); + Route::get('/create', [DirectionController::class, 'create'])->name('create'); + Route::post('/', [DirectionController::class, 'store'])->name('store'); + Route::get('/{direction}/edit', [DirectionController::class, 'edit'])->name('edit'); + Route::put('/{direction}', [DirectionController::class, 'update'])->name('update'); + Route::delete('/{direction}', [DirectionController::class, 'destroy'])->name('destroy'); + }); + + // Категории ДПО + Route::prefix('/categories')->name('categories.')->group(function () { + Route::get('/', [AdditionalEducationCategoryController::class, 'index'])->name('index'); + Route::get('/create', [AdditionalEducationCategoryController::class, 'create'])->name('create'); + Route::post('/', [AdditionalEducationCategoryController::class, 'store'])->name('store'); + Route::get('/{category}/edit', [AdditionalEducationCategoryController::class, 'edit'])->name('edit'); + Route::put('/{category}', [AdditionalEducationCategoryController::class, 'update'])->name('update'); + Route::delete('/{category}', [AdditionalEducationCategoryController::class, 'destroy'])->name('destroy'); + }); + }); + + // CRUD приемных кампаний + Route::prefix('/dashboard/admission-campaigns')->name('dashboard.admission-campaigns.')->group(function () { + Route::get('/', [AdmissionCampaignController::class, 'index'])->name('index'); + Route::get('/create', [AdmissionCampaignController::class, 'create'])->name('create'); + Route::post('/', [AdmissionCampaignController::class, 'store'])->name('store'); + Route::get('/{admissionCampaign}/edit', [AdmissionCampaignController::class, 'edit'])->name('edit'); + Route::put('/{admissionCampaign}', [AdmissionCampaignController::class, 'update'])->name('update'); + Route::delete('/{admissionCampaign}', [AdmissionCampaignController::class, 'destroy'])->name('destroy'); + }); + + // CRUD направлений подготовки + Route::prefix('/dashboard/direction-studies')->name('dashboard.direction-studies.')->group(function () { + Route::get('/', [DirectionStudyController::class, 'index'])->name('index'); + Route::get('/create', [DirectionStudyController::class, 'create'])->name('create'); + Route::post('/', [DirectionStudyController::class, 'store'])->name('store'); + Route::get('/{directionStudy}/edit', [DirectionStudyController::class, 'edit'])->name('edit'); + Route::put('/{directionStudy}', [DirectionStudyController::class, 'update'])->name('update'); + Route::delete('/{directionStudy}', [DirectionStudyController::class, 'destroy'])->name('destroy'); + }); + + // CRUD образовательных программ + Route::prefix('/dashboard/educational-programs')->name('dashboard.educational-programs.')->group(function () { + Route::get('/', [EducationalProgramController::class, 'index'])->name('index'); + Route::get('/create', [EducationalProgramController::class, 'create'])->name('create'); + Route::post('/', [EducationalProgramController::class, 'store'])->name('store'); + Route::get('/{educationalProgram}/edit', [EducationalProgramController::class, 'edit'])->name('edit'); + Route::put('/{educationalProgram}', [EducationalProgramController::class, 'update'])->name('update'); + Route::delete('/{educationalProgram}', [EducationalProgramController::class, 'destroy'])->name('destroy'); + }); + + // CRUD планов приема + Route::prefix('/dashboard/admission-plans')->name('dashboard.admission-plans.')->group(function () { + Route::get('/', [AdmissionPlanController::class, 'index'])->name('index'); + Route::get('/create', [AdmissionPlanController::class, 'create'])->name('create'); + Route::post('/', [AdmissionPlanController::class, 'store'])->name('store'); + Route::get('/{admissionPlan}/edit', [AdmissionPlanController::class, 'edit'])->name('edit'); + Route::put('/{admissionPlan}', [AdmissionPlanController::class, 'update'])->name('update'); + Route::delete('/{admissionPlan}', [AdmissionPlanController::class, 'destroy'])->name('destroy'); + }); + + // CRUD научных журналов + Route::prefix('/dashboard/academic-journals')->name('dashboard.academic-journals.')->group(function () { + Route::get('/', [AcademicJournalController::class, 'index'])->name('index'); + Route::get('/create', [AcademicJournalController::class, 'create'])->name('create'); + Route::post('/', [AcademicJournalController::class, 'store'])->name('store'); + Route::get('/{academicJournal}/edit', [AcademicJournalController::class, 'edit'])->name('edit'); + Route::put('/{academicJournal}', [AcademicJournalController::class, 'update'])->name('update'); + Route::delete('/{academicJournal}', [AcademicJournalController::class, 'destroy'])->name('destroy'); + + // Выпуски журналов (Relation Manager) + Route::prefix('/{academicJournal}/issues')->name('issues.')->group(function () { + Route::get('/', [JournalIssueController::class, 'index'])->name('index'); + Route::get('/create', [JournalIssueController::class, 'create'])->name('create'); + Route::post('/', [JournalIssueController::class, 'store'])->name('store'); + Route::get('/{issue}/edit', [JournalIssueController::class, 'edit'])->name('edit'); + Route::put('/{issue}', [JournalIssueController::class, 'update'])->name('update'); + Route::delete('/{issue}', [JournalIssueController::class, 'destroy'])->name('destroy'); + }); + }); + + // CRUD расписаний + Route::prefix('/dashboard/schedules')->name('dashboard.schedules.')->group(function () { + Route::get('/', [ScheduleController::class, 'index'])->name('index'); + Route::get('/create', [ScheduleController::class, 'create'])->name('create'); + Route::post('/', [ScheduleController::class, 'store'])->name('store'); + Route::get('/{schedule}/edit', [ScheduleController::class, 'edit'])->name('edit'); + Route::put('/{schedule}', [ScheduleController::class, 'update'])->name('update'); + Route::delete('/{schedule}', [ScheduleController::class, 'destroy'])->name('destroy'); + }); + + // Быстрая загрузка расписаний + Route::prefix('/dashboard/schedules/upload')->name('dashboard.schedules.upload.')->group(function () { + Route::get('/', [UploadSchedulesController::class, 'create'])->name('create'); + Route::post('/', [UploadSchedulesController::class, 'store'])->name('store'); + }); + // Смешанная загрузка (все файлы в одном поле) Route::post('/dashboard/files/store', ProcessMixedFilesController::class)->name('dashboard.files.store'); @@ -18,6 +211,194 @@ Route::middleware(['access-check', 'superadmin'])->group(function () { // Публикация черновика поста Route::post('/dashboard/posts/{post}/publish', PublishPostController::class)->name('dashboard.posts.publish'); + + // Быстрая загрузка файла + Route::prefix('/dashboard/quick-upload')->name('dashboard.quick-upload.')->group(function () { + Route::get('/', [QuickUploadController::class, 'create'])->name('create'); + Route::post('/', [QuickUploadController::class, 'store'])->name('store'); + }); + + // CRUD слайдеров + Route::prefix('/dashboard/sliders')->name('dashboard.sliders.')->group(function () { + Route::get('/', [SliderController::class, 'index'])->name('index'); + Route::get('/create', [CreateSliderController::class, '__invoke'])->name('create'); + Route::post('/', [StoreSliderController::class, '__invoke'])->name('store'); + Route::get('/{slider}/edit', [EditSliderController::class, '__invoke'])->name('edit'); + Route::put('/{slider}', [UpdateSliderController::class, '__invoke'])->name('update'); + Route::delete('/{slider}', [DestroySliderController::class, '__invoke'])->name('destroy'); + }); + + // CRUD слайдов + Route::prefix('/dashboard/slides')->name('dashboard.slides.')->group(function () { + Route::post('/slider/{slider}', [StoreSlideController::class, '__invoke'])->name('store'); + Route::put('/{slide}', [UpdateSlideController::class, '__invoke'])->name('update'); + Route::delete('/{slide}', [DestroySlideController::class, '__invoke'])->name('destroy'); + Route::put('/slider/{slider}/order', [UpdateSlidesOrderController::class, '__invoke'])->name('order'); + }); + + // CRUD факультетов + Route::prefix('/dashboard/faculties')->name('dashboard.faculties.')->group(function () { + Route::get('/', [FacultyController::class, 'index'])->name('index'); + Route::get('/create', [FacultyController::class, 'create'])->name('create'); + Route::post('/', [FacultyController::class, 'store'])->name('store'); + Route::get('/{faculty}/edit', [FacultyController::class, 'edit'])->name('edit'); + Route::put('/{faculty}', [FacultyController::class, 'update'])->name('update'); + Route::delete('/{faculty}', [FacultyController::class, 'destroy'])->name('destroy'); + }); + + // Сотрудники факультетов + Route::prefix('/dashboard/faculties/{faculty}/workers')->name('dashboard.faculties.workers.')->group(function () { + Route::get('/', [FacultyWorkerController::class, 'index'])->name('index'); + Route::post('/', [FacultyWorkerController::class, 'attach'])->name('attach'); + Route::put('/{worker}', [FacultyWorkerController::class, 'update'])->name('update'); + Route::delete('/{worker}', [FacultyWorkerController::class, 'detach'])->name('detach'); + }); + + // CRUD подразделений + Route::prefix('/dashboard/divisions')->name('dashboard.divisions.')->group(function () { + Route::get('/', [DivisionController::class, 'index'])->name('index'); + Route::get('/create', [DivisionController::class, 'create'])->name('create'); + Route::post('/', [DivisionController::class, 'store'])->name('store'); + Route::get('/{division}/edit', [DivisionController::class, 'edit'])->name('edit'); + Route::put('/{division}', [DivisionController::class, 'update'])->name('update'); + Route::delete('/{division}', [DivisionController::class, 'destroy'])->name('destroy'); + }); + + // Сотрудники подразделений + Route::prefix('/dashboard/divisions/{division}/workers')->name('dashboard.divisions.workers.')->group(function () { + Route::get('/', [DivisionWorkerController::class, 'index'])->name('index'); + Route::post('/', [DivisionWorkerController::class, 'attach'])->name('attach'); + Route::put('/{worker}', [DivisionWorkerController::class, 'update'])->name('update'); + Route::delete('/{worker}', [DivisionWorkerController::class, 'detach'])->name('detach'); + }); + + // CRUD кафедр + Route::prefix('/dashboard/departments')->name('dashboard.departments.')->group(function () { + Route::get('/', [DepartmentController::class, 'index'])->name('index'); + Route::get('/create', [DepartmentController::class, 'create'])->name('create'); + Route::post('/', [DepartmentController::class, 'store'])->name('store'); + Route::get('/{department}/edit', [DepartmentController::class, 'edit'])->name('edit'); + Route::put('/{department}', [DepartmentController::class, 'update'])->name('update'); + Route::delete('/{department}', [DepartmentController::class, 'destroy'])->name('destroy'); + }); + + // Сотрудники кафедр + Route::prefix('/dashboard/departments/{department}/workers')->name('dashboard.departments.workers.')->group(function () { + Route::get('/', [DepartmentWorkerController::class, 'index'])->name('index'); + Route::post('/', [DepartmentWorkerController::class, 'attach'])->name('attach'); + Route::put('/{worker}', [DepartmentWorkerController::class, 'update'])->name('update'); + Route::delete('/{worker}', [DepartmentWorkerController::class, 'detach'])->name('detach'); + }); + + // Преподаватели кафедр + Route::prefix('/dashboard/departments/{department}/teachers')->name('dashboard.departments.teachers.')->group(function () { + Route::get('/', [DepartmentTeacherController::class, 'index'])->name('index'); + Route::post('/', [DepartmentTeacherController::class, 'attach'])->name('attach'); + Route::put('/{teacher}', [DepartmentTeacherController::class, 'update'])->name('update'); + Route::delete('/{teacher}', [DepartmentTeacherController::class, 'detach'])->name('detach'); + }); + + // Образовательные программы кафедр + Route::prefix('/dashboard/departments/{department}/programs')->name('dashboard.departments.programs.')->group(function () { + Route::get('/', [DepartmentProgramController::class, 'index'])->name('index'); + Route::post('/', [DepartmentProgramController::class, 'attach'])->name('attach'); + Route::delete('/{program}', [DepartmentProgramController::class, 'detach'])->name('detach'); + }); + + // CRUD пользователей + Route::prefix('/dashboard/users')->name('dashboard.users.')->group(function () { + Route::get('/', [UserController::class, 'index'])->name('index'); + Route::get('/create', [UserController::class, 'create'])->name('create'); + Route::post('/', [UserController::class, 'store'])->name('store'); + Route::get('/{user}/edit', [UserController::class, 'edit'])->name('edit'); + Route::put('/{user}', [UserController::class, 'update'])->name('update'); + Route::delete('/{user}', [UserController::class, 'destroy'])->name('destroy'); + Route::post('/invite', [UserController::class, 'invite'])->name('invite'); + + // Детальная информация пользователя (Relation Manager) + Route::prefix('/{user}/detail')->name('detail.')->group(function () { + Route::get('/create', [UserDetailController::class, 'create'])->name('create'); + Route::post('/', [UserDetailController::class, 'store'])->name('store'); + Route::get('/{userDetail}/edit', [UserDetailController::class, 'edit'])->name('edit'); + Route::put('/{userDetail}', [UserDetailController::class, 'update'])->name('update'); + Route::delete('/{userDetail}', [UserDetailController::class, 'destroy'])->name('destroy'); + }); + }); + + // CRUD главных разделов + Route::prefix('/dashboard/main-sections')->name('dashboard.main-sections.')->group(function () { + Route::get('/', [MainSectionController::class, 'index'])->name('index'); + Route::get('/create', [MainSectionController::class, 'create'])->name('create'); + Route::post('/', [MainSectionController::class, 'store'])->name('store'); + Route::get('/{mainSection}/edit', [MainSectionController::class, 'edit'])->name('edit'); + Route::put('/{mainSection}', [MainSectionController::class, 'update'])->name('update'); + Route::delete('/{mainSection}', [MainSectionController::class, 'destroy'])->name('destroy'); + }); + + // CRUD подразделов + Route::prefix('/dashboard/sub-sections')->name('dashboard.sub-sections.')->group(function () { + Route::get('/', [SubSectionController::class, 'index'])->name('index'); + Route::get('/create', [SubSectionController::class, 'create'])->name('create'); + Route::post('/', [SubSectionController::class, 'store'])->name('store'); + Route::get('/{subSection}/edit', [SubSectionController::class, 'edit'])->name('edit'); + Route::put('/{subSection}', [SubSectionController::class, 'update'])->name('update'); + Route::delete('/{subSection}', [SubSectionController::class, 'destroy'])->name('destroy'); + + // Управление привязкой к главным разделам + Route::post('/{subSection}/attach-to-main-section', [SubSectionController::class, 'attachToMainSection'])->name('attach-to-main-section'); + Route::post('/{subSection}/detach-from-main-section', [SubSectionController::class, 'detachFromMainSection'])->name('detach-from-main-section'); + + // Управление страницами (Relation Manager) + Route::post('/{subSection}/pages/attach', [SubSectionController::class, 'attachPage'])->name('pages.attach'); + Route::delete('/{subSection}/pages/{page}/detach', [SubSectionController::class, 'detachPage'])->name('pages.detach'); + }); + + // CRUD страниц + Route::prefix('/dashboard/pages')->name('dashboard.pages.')->group(function () { + Route::get('/', [PageController::class, 'index'])->name('index'); + Route::get('/create', [PageController::class, 'create'])->name('create'); + Route::post('/', [PageController::class, 'store'])->name('store'); + Route::get('/{page}/edit', [PageController::class, 'edit'])->name('edit'); + Route::put('/{page}', [PageController::class, 'update'])->name('update'); + Route::delete('/{page}', [PageController::class, 'destroy'])->name('destroy'); + }); + + // CRUD контактных виджетов + Route::prefix('/dashboard/contact-widgets')->name('dashboard.contact-widgets.')->group(function () { + Route::get('/', [ContactWidgetController::class, 'index'])->name('index'); + Route::get('/create', [ContactWidgetController::class, 'create'])->name('create'); + Route::post('/', [ContactWidgetController::class, 'store'])->name('store'); + Route::get('/{contactWidget}/edit', [ContactWidgetController::class, 'edit'])->name('edit'); + Route::put('/{contactWidget}', [ContactWidgetController::class, 'update'])->name('update'); + Route::delete('/{contactWidget}', [ContactWidgetController::class, 'destroy'])->name('destroy'); + }); + + // CRUD пользовательских форм + Route::prefix('/dashboard/custom-forms')->name('dashboard.custom-forms.')->group(function () { + Route::get('/', [CustomFormController::class, 'index'])->name('index'); + Route::get('/create', [CustomFormController::class, 'create'])->name('create'); + Route::post('/', [CustomFormController::class, 'store'])->name('store'); + Route::get('/{customForm}/edit', [CustomFormController::class, 'edit'])->name('edit'); + Route::put('/{customForm}', [CustomFormController::class, 'update'])->name('update'); + Route::delete('/{customForm}', [CustomFormController::class, 'destroy'])->name('destroy'); + + // Ответы на форму (Relation Manager) + Route::prefix('/{customForm}/responses')->name('responses.')->group(function () { + Route::get('/', [CustomFormController::class, 'responses'])->name('index'); + Route::post('/{response}/toggle-checked', [CustomFormController::class, 'toggleResponseChecked'])->name('toggle-checked'); + Route::delete('/{response}', [CustomFormController::class, 'destroyResponse'])->name('destroy'); + }); + }); + + // CRUD списков ресурсов + Route::prefix('/dashboard/page-reference-lists')->name('dashboard.page-reference-lists.')->group(function () { + Route::get('/', [PageReferenceListController::class, 'index'])->name('index'); + Route::get('/create', [PageReferenceListController::class, 'create'])->name('create'); + Route::post('/', [PageReferenceListController::class, 'store'])->name('store'); + Route::get('/{pageReferenceList}/edit', [PageReferenceListController::class, 'edit'])->name('edit'); + Route::put('/{pageReferenceList}', [PageReferenceListController::class, 'update'])->name('update'); + Route::delete('/{pageReferenceList}', [PageReferenceListController::class, 'destroy'])->name('destroy'); + }); }); diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index 6b5c487..ebac56f 100755 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -86,6 +86,7 @@ class Kernel extends HttpKernel 'rate.limited.check' => RateLimitCheckMiddleware::class, 'ensure.browser' => InternalRequestOnly::class, 'superadmin' => \App\Http\Middleware\EnsureUserIsSuperadmin::class, + 'dashboard.auth' => \App\Containers\Dashboard\UI\WEB\Middleware\EnsureDashboardAuthenticated::class, 'limit.post' => LimitPost::class, 'form.time.period' => FormTimePeriodMiddleware::class, diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index 4055e9f..c766982 100755 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -21,6 +21,11 @@ class HandleInertiaRequests extends Middleware public function share(Request $request): array { + // Отключаем SSR для Dashboard роутов + if (str_starts_with($request->path(), 'dashboard')) { + config(['inertia.ssr.enabled' => false]); + } + return [ ...parent::share($request), 'auth' => [ diff --git a/app/Providers/RouteServiceProvider.php b/app/Providers/RouteServiceProvider.php index 28da75d..025e874 100755 --- a/app/Providers/RouteServiceProvider.php +++ b/app/Providers/RouteServiceProvider.php @@ -17,7 +17,7 @@ class RouteServiceProvider extends ServiceProvider * * @var string */ - public const HOME = '/admin'; + public const HOME = '/dashboard'; /** * Define your route model bindings, pattern filters, and other route configuration. diff --git a/app/Ship/Kernels/HttpKernel.php b/app/Ship/Kernels/HttpKernel.php index 263426a..f1297d6 100644 --- a/app/Ship/Kernels/HttpKernel.php +++ b/app/Ship/Kernels/HttpKernel.php @@ -87,6 +87,7 @@ class HttpKernel extends LaravelHttpKernel 'rate.limited.check' => RateLimitCheckMiddleware::class, 'ensure.browser' => InternalRequestOnly::class, 'superadmin' => EnsureUserIsSuperadmin::class, + 'dashboard.auth' => \App\Containers\Dashboard\UI\WEB\Middleware\EnsureDashboardAuthenticated::class, 'limit.post' => LimitPost::class, 'form.time.period' => FormTimePeriodMiddleware::class, ]; diff --git a/app/Ship/Providers/RouteServiceProvider.php b/app/Ship/Providers/RouteServiceProvider.php index 6c21b55..7aca03a 100755 --- a/app/Ship/Providers/RouteServiceProvider.php +++ b/app/Ship/Providers/RouteServiceProvider.php @@ -16,7 +16,7 @@ class RouteServiceProvider extends AbstractRouteServiceProvider * * @var string */ - public const HOME = '/home'; + public const HOME = '/dashboard'; /** * Define your route model bindings, pattern filters, and other route configuration. diff --git a/docs/page-visual-customization.md b/docs/page-visual-customization.md new file mode 100644 index 0000000..6a22878 --- /dev/null +++ b/docs/page-visual-customization.md @@ -0,0 +1,791 @@ +# Визуальная кастомизация страниц — Анализ и план реализации + +> **Дата:** 7 апреля 2026 г. +> **Контекст:** Запрос на функционал изменения визуала страниц через админку (отступы, шрифты, размеры, цвета и т.д.) + +--- + +## 📊 Текущая архитектура страниц + +В проекте существуют **два типа страниц**: + +### Тип A: Database-driven страницы (Page Builder) + +| Параметр | Значение | +|----------|----------| +| **Создаются** | Через Filament админку | +| **Хранение** | JSON в колонке `content` таблицы `pages` | +| **Рендеринг** | Универсальный `Page.vue` → `Builder` компонент → динамический рендер блоков | +| **Стилизация** | Hardcoded Tailwind классы в блоках (`HeadingBlock.vue`, `ParagraphBlock.vue`, и т.д.) | +| **Настройки** | Колонка `settings` (JSON) — только **логические флаги** (скрыть breadcrumbs, навигацию и т.д.) | + +**Ключевые файлы:** +- Model: `app/Containers/AppStructure/Models/Page.php` +- Controller: `app/Containers/AppStructure/UI/WEB/Controllers/PageController.php` +- Action: `app/Containers/AppStructure/Actions/RenderPageAction.php` +- Vue: `resources/js/Pages/Page.vue` +- Builder: `resources/js/componentss/shared/builder/pageBuilder/Builder.vue` +- Filament Form: `app/Filament/Components/Forms/PageForm.php` +- Filament Builder: `app/Filament/Components/Forms/ItemForm/Pages/ContentBuilderItem.php` + +### Тип B: Code-driven страницы (Hardcoded Vue компоненты) + +| Параметр | Значение | +|----------|----------| +| **Создаются** | Как `.vue` файлы в `resources/js/Pages/` | +| **Примеры** | `Main.vue` (главная), страницы Dashboard | +| **Стилизация** | Tailwind классы напрямую в template | +| **Авто-регистрация** | `RegisterApplicationRoutesTask` создаёт запись в `pages` с `is_registered = true` | + +**Ключевые файлы:** +- Task: `app/Containers/AppStructure/Tasks/RegisterApplicationRoutesTask.php` +- Middleware: `app/Ship/Middleware/AccessCheck.php` +- Пример: `resources/js/Pages/Main.vue` + +### Текущая структура `pages.settings` + +```json +{ + "hide_page_sub_section_links": false, + "hide_page_navigate_links": false, + "hide_breadcrumbs": false, + "form": { + "id": "form_id", + "title": "Заголовок", + "description": "Описание", + "button": "Текст кнопки" + } +} +``` + +### Текущая структура `pages` таблицы + +| Колонка | Тип | Описание | +|---------|-----|----------| +| `id` | bigint | Primary key | +| `title` | string(255) | Заголовок страницы | +| `content` | longText (JSON) | Builder блоки | +| `slug` | string(255) | URL сегмент | +| `path` | string | Полный URL путь | +| `is_registered` | boolean | true = авто-регистрация из кода | +| `is_visible` | boolean | Видимость | +| `searchable` | boolean | Индексация в поиске | +| `is_url` | boolean | Редирект на внешний URL | +| `code` | integer | HTTP статус (200, 404, 500) | +| `sub_section_id` | bigint FK | Связь с подразделом | +| `settings` | longText (JSON) | Настройки отображения | +| `icon` | string | Heroicon для навигации | +| `search_data` | longText | Текст для поиска | + +--- + +## 🎯 Проблема + +Сейчас **нет возможности** через админку менять визуальные параметры страниц: +- ❌ Отступы (padding, margin) +- ❌ Размер шрифта +- ❌ Шрифт (font-family) +- ❌ Цвета +- ❌ Максимальная ширина контента +- ❌ И другие CSS-свойства + +Все стили **захардкожены** в Vue компонентах и блоках билдера. + +--- + +## 💡 Решения (от простого к сложному) + +### Решение 1: Расширение `settings` JSON (Рекомендуемое) + +**Концепция:** Добавить в существующую колонку `pages.settings` секцию `visual` с визуальными настройками. + +**Структура данных:** +```json +{ + "hide_page_sub_section_links": false, + "hide_page_navigate_links": false, + "hide_breadcrumbs": false, + "form": { ... }, + "visual": { + "typography": { + "font_family": "Inter", + "title_size": "2xl", + "body_size": "base", + "line_height": "normal" + }, + "spacing": { + "container_padding_top": "10", + "container_padding_bottom": "10", + "container_padding_x": "4", + "content_gap": "5" + }, + "layout": { + "max_width": "screen-xl", + "sidebar_position": "left" + }, + "colors": { + "background": "white", + "text": "gray-900" + } + } +} +``` + +**Backend (Filament Form) — пример добавления в PageForm.php:** +```php +Section::make('Визуальные настройки') + ->description('Настройка внешнего вида страницы') + ->collapsible() + ->schema([ + Select::make('visual.typography.font_family') + ->label('Шрифт') + ->options([ + 'Inter' => 'Inter (по умолчанию)', + 'Roboto' => 'Roboto', + 'Open Sans' => 'Open Sans', + 'Montserrat' => 'Montserrat', + ]) + ->default('Inter'), + + Select::make('visual.typography.title_size') + ->label('Размер заголовка') + ->options([ + 'xl' => 'XL (маленький)', + '2xl' => '2XL (стандарт)', + '3xl' => '3XL (большой)', + '4xl' => '4XL (очень большой)', + ]) + ->default('2xl'), + + Select::make('visual.spacing.container_padding_top') + ->label('Отступ сверху') + ->options([ + '0' => '0', + '4' => '16px', + '6' => '24px', + '10' => '40px', + ]) + ->default('10'), + ]); +``` + +**Frontend (Page.vue) — пример применения:** +```vue + + + +``` + +**Плюсы:** +- ✅ Минимальные изменения в архитектуре +- ✅ Использует существующую инфраструктуру `settings` +- ✅ Легко масштабировать (добавить новые поля в JSON) +- ✅ Не требует миграций БД +- ✅ Работает для **обоих типов страниц** (нужно только передать `settings` в Inertia) + +**Минусы:** +- ❌ Ограниченный набор стилей (только то, что предусмотрено в UI) +- ❌ Нужно менять все Builder-блоки для поддержки наследования стилей + +--- + +### Решение 2: CSS Custom Properties (CSS Variables) + +**Концепция:** Хранить CSS-переменные в `settings`, применять через inline ` + +
+ +
+ + + + +``` + +**Плюсы:** +- ✅ Гибкость — можно задать **любое** CSS-свойство +- ✅ Каскадное применение — переменные наследуются вниз +- ✅ Не нужно менять все Builder-блоки (применяются глобально) +- ✅ Легко реализовать в админке (ключ-значение форма) + +**Минусы:** +- ❌ Требует знания CSS у контент-менеджеров (или ограниченный набор в UI) +- ❌ Могут быть конфликты с Tailwind классами +- ❌ Сложнее валидировать значения + +--- + +### Решение 3: Page Themes / Templates System + +**Концепция:** Создать систему **тем** — предустановленных наборов стилей. + +**Структура данных:** + +**Таблица `page_themes`:** +```sql +id | name | description | styles (JSON) | is_active +1 | Default | Стандартная тема | {...} | true +2 | Compact | Компактная | {...} | false +3 | Spacious | Просторная | {...} | false +``` + +**`pages` таблица — новые колонки:** +```sql +theme_id (FK -> page_themes) +custom_overrides (JSON) — индивидуальные переопределения +``` + +**Пример `styles` в теме:** +```json +{ + "typography": { + "font_family": "Inter", + "title_sizes": { "h1": "3xl", "h2": "2xl", "h3": "xl" }, + "body_size": "base", + "line_height": "relaxed" + }, + "spacing": { + "container": { "max_width": "screen-xl", "px": "4", "py": "10" }, + "content_gap": "space-y-5" + }, + "colors": { + "background": "white", + "text": "gray-900", + "accent": "primary" + }, + "borders": { + "radius": "rounded-lg", + "shadow": "shadow-sm" + } +} +``` + +**Filament админка:** +- CRUD для `PageTheme` (создание/редактирование тем) +- В `PageForm`: `Select::make('theme_id')->relationship('theme', 'name')` +- Опционально: overrides для конкретной страницы + +**Frontend:** +```vue + +``` + +**Плюсы:** +- ✅ **Масштабируемость** — одна тема применяется к множеству страниц +- ✅ **Безопасность** — админ не сломает стили (выбирает из готовых) +- ✅ **A/B тестирование** — легко менять темы +- ✅ Разделение ответственности: дизайнер создаёт темы, контент-менеджер выбирает + +**Минусы:** +- ❌ **Сложность реализации** — новая таблица, CRUD, UI для тем +- ❌ Требует больше времени на разработку +- ❌ Нужно менять `PageResource`, `RenderPageAction`, `Page.vue` + +--- + +### Решение 4: Custom CSS per Page (Полная свобода) + +**Концепция:** Позволить админу писать **произвольный CSS** для страницы. + +**Структура данных:** +```json +{ + "visual": { + "custom_css": "#page-area h1 { font-size: 2.5rem; color: #2D4191; }\n#page-area p { line-height: 1.8; }" + } +} +``` + +**Frontend:** +```vue +