diff --git a/.cursor/mcp.json b/.cursor/mcp.json new file mode 100644 index 0000000..b157908 --- /dev/null +++ b/.cursor/mcp.json @@ -0,0 +1,19 @@ +{ + "mcpServers": { + "task-master-ai": { + "command": "npx", + "args": ["-y", "--package=task-master-ai", "task-master-ai"], + "env": { + "ANTHROPIC_API_KEY": "YOUR_ANTHROPIC_API_KEY_HERE", + "PERPLEXITY_API_KEY": "YOUR_PERPLEXITY_API_KEY_HERE", + "OPENAI_API_KEY": "YOUR_OPENAI_KEY_HERE", + "GOOGLE_API_KEY": "YOUR_GOOGLE_KEY_HERE", + "XAI_API_KEY": "YOUR_XAI_KEY_HERE", + "OPENROUTER_API_KEY": "YOUR_OPENROUTER_KEY_HERE", + "MISTRAL_API_KEY": "YOUR_MISTRAL_KEY_HERE", + "AZURE_OPENAI_API_KEY": "YOUR_AZURE_KEY_HERE", + "OLLAMA_API_KEY": "YOUR_OLLAMA_API_KEY_HERE" + } + } + } +} diff --git a/.cursor/rules/cursor_rules.mdc b/.cursor/rules/cursor_rules.mdc new file mode 100644 index 0000000..7dfae3d --- /dev/null +++ b/.cursor/rules/cursor_rules.mdc @@ -0,0 +1,53 @@ +--- +description: Guidelines for creating and maintaining Cursor rules to ensure consistency and effectiveness. +globs: .cursor/rules/*.mdc +alwaysApply: true +--- + +- **Required Rule Structure:** + ```markdown + --- + description: Clear, one-line description of what the rule enforces + globs: path/to/files/*.ext, other/path/**/* + alwaysApply: boolean + --- + + - **Main Points in Bold** + - Sub-points with details + - Examples and explanations + ``` + +- **File References:** + - Use `[filename](mdc:path/to/file)` ([filename](mdc:filename)) to reference files + - Example: [prisma.mdc](mdc:.cursor/rules/prisma.mdc) for rule references + - Example: [schema.prisma](mdc:prisma/schema.prisma) for code references + +- **Code Examples:** + - Use language-specific code blocks + ```typescript + // ✅ DO: Show good examples + const goodExample = true; + + // ❌ DON'T: Show anti-patterns + const badExample = false; + ``` + +- **Rule Content Guidelines:** + - Start with high-level overview + - Include specific, actionable requirements + - Show examples of correct implementation + - Reference existing code when possible + - Keep rules DRY by referencing other rules + +- **Rule Maintenance:** + - Update rules when new patterns emerge + - Add examples from actual codebase + - Remove outdated patterns + - Cross-reference related rules + +- **Best Practices:** + - Use bullet points for clarity + - Keep descriptions concise + - Include both DO and DON'T examples + - Reference actual code over theoretical examples + - Use consistent formatting across rules \ No newline at end of file diff --git a/.cursor/rules/self_improve.mdc b/.cursor/rules/self_improve.mdc new file mode 100644 index 0000000..40b31b6 --- /dev/null +++ b/.cursor/rules/self_improve.mdc @@ -0,0 +1,72 @@ +--- +description: Guidelines for continuously improving Cursor rules based on emerging code patterns and best practices. +globs: **/* +alwaysApply: true +--- + +- **Rule Improvement Triggers:** + - New code patterns not covered by existing rules + - Repeated similar implementations across files + - Common error patterns that could be prevented + - New libraries or tools being used consistently + - Emerging best practices in the codebase + +- **Analysis Process:** + - Compare new code with existing rules + - Identify patterns that should be standardized + - Look for references to external documentation + - Check for consistent error handling patterns + - Monitor test patterns and coverage + +- **Rule Updates:** + - **Add New Rules When:** + - A new technology/pattern is used in 3+ files + - Common bugs could be prevented by a rule + - Code reviews repeatedly mention the same feedback + - New security or performance patterns emerge + + - **Modify Existing Rules When:** + - Better examples exist in the codebase + - Additional edge cases are discovered + - Related rules have been updated + - Implementation details have changed + +- **Example Pattern Recognition:** + ```typescript + // If you see repeated patterns like: + const data = await prisma.user.findMany({ + select: { id: true, email: true }, + where: { status: 'ACTIVE' } + }); + + // Consider adding to [prisma.mdc](mdc:.cursor/rules/prisma.mdc): + // - Standard select fields + // - Common where conditions + // - Performance optimization patterns + ``` + +- **Rule Quality Checks:** + - Rules should be actionable and specific + - Examples should come from actual code + - References should be up to date + - Patterns should be consistently enforced + +- **Continuous Improvement:** + - Monitor code review comments + - Track common development questions + - Update rules after major refactors + - Add links to relevant documentation + - Cross-reference related rules + +- **Rule Deprecation:** + - Mark outdated patterns as deprecated + - Remove rules that no longer apply + - Update references to deprecated rules + - Document migration paths for old patterns + +- **Documentation Updates:** + - Keep examples synchronized with code + - Update references to external docs + - Maintain links between related rules + - Document breaking changes +Follow [cursor_rules.mdc](mdc:.cursor/rules/cursor_rules.mdc) for proper rule formatting and structure. diff --git a/.cursor/rules/taskmaster/dev_workflow.mdc b/.cursor/rules/taskmaster/dev_workflow.mdc new file mode 100644 index 0000000..84dd906 --- /dev/null +++ b/.cursor/rules/taskmaster/dev_workflow.mdc @@ -0,0 +1,424 @@ +--- +description: Guide for using Taskmaster to manage task-driven development workflows +globs: **/* +alwaysApply: true +--- + +# Taskmaster Development Workflow + +This guide outlines the standard process for using Taskmaster to manage software development projects. It is written as a set of instructions for you, the AI agent. + +- **Your Default Stance**: For most projects, the user can work directly within the `master` task context. Your initial actions should operate on this default context unless a clear pattern for multi-context work emerges. +- **Your Goal**: Your role is to elevate the user's workflow by intelligently introducing advanced features like **Tagged Task Lists** when you detect the appropriate context. Do not force tags on the user; suggest them as a helpful solution to a specific need. + +## The Basic Loop +The fundamental development cycle you will facilitate is: +1. **`list`**: Show the user what needs to be done. +2. **`next`**: Help the user decide what to work on. +3. **`show `**: Provide details for a specific task. +4. **`expand `**: Break down a complex task into smaller, manageable subtasks. +5. **Implement**: The user writes the code and tests. +6. **`update-subtask`**: Log progress and findings on behalf of the user. +7. **`set-status`**: Mark tasks and subtasks as `done` as work is completed. +8. **Repeat**. + +All your standard command executions should operate on the user's current task context, which defaults to `master`. + +--- + +## Standard Development Workflow Process + +### Simple Workflow (Default Starting Point) + +For new projects or when users are getting started, operate within the `master` tag context: + +- Start new projects by running `initialize_project` tool / `task-master init` or `parse_prd` / `task-master parse-prd --input=''` (see @`taskmaster.mdc`) to generate initial tasks.json with tagged structure +- Configure rule sets during initialization with `--rules` flag (e.g., `task-master init --rules cursor,windsurf`) or manage them later with `task-master rules add/remove` commands +- Begin coding sessions with `get_tasks` / `task-master list` (see @`taskmaster.mdc`) to see current tasks, status, and IDs +- Determine the next task to work on using `next_task` / `task-master next` (see @`taskmaster.mdc`) +- Analyze task complexity with `analyze_project_complexity` / `task-master analyze-complexity --research` (see @`taskmaster.mdc`) before breaking down tasks +- Review complexity report using `complexity_report` / `task-master complexity-report` (see @`taskmaster.mdc`) +- Select tasks based on dependencies (all marked 'done'), priority level, and ID order +- View specific task details using `get_task` / `task-master show ` (see @`taskmaster.mdc`) to understand implementation requirements +- Break down complex tasks using `expand_task` / `task-master expand --id= --force --research` (see @`taskmaster.mdc`) with appropriate flags like `--force` (to replace existing subtasks) and `--research` +- Implement code following task details, dependencies, and project standards +- Mark completed tasks with `set_task_status` / `task-master set-status --id= --status=done` (see @`taskmaster.mdc`) +- Update dependent tasks when implementation differs from original plan using `update` / `task-master update --from= --prompt="..."` or `update_task` / `task-master update-task --id= --prompt="..."` (see @`taskmaster.mdc`) + +--- + +## Leveling Up: Agent-Led Multi-Context Workflows + +While the basic workflow is powerful, your primary opportunity to add value is by identifying when to introduce **Tagged Task Lists**. These patterns are your tools for creating a more organized and efficient development environment for the user, especially if you detect agentic or parallel development happening across the same session. + +**Critical Principle**: Most users should never see a difference in their experience. Only introduce advanced workflows when you detect clear indicators that the project has evolved beyond simple task management. + +### When to Introduce Tags: Your Decision Patterns + +Here are the patterns to look for. When you detect one, you should propose the corresponding workflow to the user. + +#### Pattern 1: Simple Git Feature Branching +This is the most common and direct use case for tags. + +- **Trigger**: The user creates a new git branch (e.g., `git checkout -b feature/user-auth`). +- **Your Action**: Propose creating a new tag that mirrors the branch name to isolate the feature's tasks from `master`. +- **Your Suggested Prompt**: *"I see you've created a new branch named 'feature/user-auth'. To keep all related tasks neatly organized and separate from your main list, I can create a corresponding task tag for you. This helps prevent merge conflicts in your `tasks.json` file later. Shall I create the 'feature-user-auth' tag?"* +- **Tool to Use**: `task-master add-tag --from-branch` + +#### Pattern 2: Team Collaboration +- **Trigger**: The user mentions working with teammates (e.g., "My teammate Alice is handling the database schema," or "I need to review Bob's work on the API."). +- **Your Action**: Suggest creating a separate tag for the user's work to prevent conflicts with shared master context. +- **Your Suggested Prompt**: *"Since you're working with Alice, I can create a separate task context for your work to avoid conflicts. This way, Alice can continue working with the master list while you have your own isolated context. When you're ready to merge your work, we can coordinate the tasks back to master. Shall I create a tag for your current work?"* +- **Tool to Use**: `task-master add-tag my-work --copy-from-current --description="My tasks while collaborating with Alice"` + +#### Pattern 3: Experiments or Risky Refactors +- **Trigger**: The user wants to try something that might not be kept (e.g., "I want to experiment with switching our state management library," or "Let's refactor the old API module, but I want to keep the current tasks as a reference."). +- **Your Action**: Propose creating a sandboxed tag for the experimental work. +- **Your Suggested Prompt**: *"This sounds like a great experiment. To keep these new tasks separate from our main plan, I can create a temporary 'experiment-zustand' tag for this work. If we decide not to proceed, we can simply delete the tag without affecting the main task list. Sound good?"* +- **Tool to Use**: `task-master add-tag experiment-zustand --description="Exploring Zustand migration"` + +#### Pattern 4: Large Feature Initiatives (PRD-Driven) +This is a more structured approach for significant new features or epics. + +- **Trigger**: The user describes a large, multi-step feature that would benefit from a formal plan. +- **Your Action**: Propose a comprehensive, PRD-driven workflow. +- **Your Suggested Prompt**: *"This sounds like a significant new feature. To manage this effectively, I suggest we create a dedicated task context for it. Here's the plan: I'll create a new tag called 'feature-xyz', then we can draft a Product Requirements Document (PRD) together to scope the work. Once the PRD is ready, I'll automatically generate all the necessary tasks within that new tag. How does that sound?"* +- **Your Implementation Flow**: + 1. **Create an empty tag**: `task-master add-tag feature-xyz --description "Tasks for the new XYZ feature"`. You can also start by creating a git branch if applicable, and then create the tag from that branch. + 2. **Collaborate & Create PRD**: Work with the user to create a detailed PRD file (e.g., `.taskmaster/docs/feature-xyz-prd.txt`). + 3. **Parse PRD into the new tag**: `task-master parse-prd .taskmaster/docs/feature-xyz-prd.txt --tag feature-xyz` + 4. **Prepare the new task list**: Follow up by suggesting `analyze-complexity` and `expand-all` for the newly created tasks within the `feature-xyz` tag. + +#### Pattern 5: Version-Based Development +Tailor your approach based on the project maturity indicated by tag names. + +- **Prototype/MVP Tags** (`prototype`, `mvp`, `poc`, `v0.x`): + - **Your Approach**: Focus on speed and functionality over perfection + - **Task Generation**: Create tasks that emphasize "get it working" over "get it perfect" + - **Complexity Level**: Lower complexity, fewer subtasks, more direct implementation paths + - **Research Prompts**: Include context like "This is a prototype - prioritize speed and basic functionality over optimization" + - **Example Prompt Addition**: *"Since this is for the MVP, I'll focus on tasks that get core functionality working quickly rather than over-engineering."* + +- **Production/Mature Tags** (`v1.0+`, `production`, `stable`): + - **Your Approach**: Emphasize robustness, testing, and maintainability + - **Task Generation**: Include comprehensive error handling, testing, documentation, and optimization + - **Complexity Level**: Higher complexity, more detailed subtasks, thorough implementation paths + - **Research Prompts**: Include context like "This is for production - prioritize reliability, performance, and maintainability" + - **Example Prompt Addition**: *"Since this is for production, I'll ensure tasks include proper error handling, testing, and documentation."* + +### Advanced Workflow (Tag-Based & PRD-Driven) + +**When to Transition**: Recognize when the project has evolved (or has initiated a project which existing code) beyond simple task management. Look for these indicators: +- User mentions teammates or collaboration needs +- Project has grown to 15+ tasks with mixed priorities +- User creates feature branches or mentions major initiatives +- User initializes Taskmaster on an existing, complex codebase +- User describes large features that would benefit from dedicated planning + +**Your Role in Transition**: Guide the user to a more sophisticated workflow that leverages tags for organization and PRDs for comprehensive planning. + +#### Master List Strategy (High-Value Focus) +Once you transition to tag-based workflows, the `master` tag should ideally contain only: +- **High-level deliverables** that provide significant business value +- **Major milestones** and epic-level features +- **Critical infrastructure** work that affects the entire project +- **Release-blocking** items + +**What NOT to put in master**: +- Detailed implementation subtasks (these go in feature-specific tags' parent tasks) +- Refactoring work (create dedicated tags like `refactor-auth`) +- Experimental features (use `experiment-*` tags) +- Team member-specific tasks (use person-specific tags) + +#### PRD-Driven Feature Development + +**For New Major Features**: +1. **Identify the Initiative**: When user describes a significant feature +2. **Create Dedicated Tag**: `add_tag feature-[name] --description="[Feature description]"` +3. **Collaborative PRD Creation**: Work with user to create comprehensive PRD in `.taskmaster/docs/feature-[name]-prd.txt` +4. **Parse & Prepare**: + - `parse_prd .taskmaster/docs/feature-[name]-prd.txt --tag=feature-[name]` + - `analyze_project_complexity --tag=feature-[name] --research` + - `expand_all --tag=feature-[name] --research` +5. **Add Master Reference**: Create a high-level task in `master` that references the feature tag + +**For Existing Codebase Analysis**: +When users initialize Taskmaster on existing projects: +1. **Codebase Discovery**: Use your native tools for producing deep context about the code base. You may use `research` tool with `--tree` and `--files` to collect up to date information using the existing architecture as context. +2. **Collaborative Assessment**: Work with user to identify improvement areas, technical debt, or new features +3. **Strategic PRD Creation**: Co-author PRDs that include: + - Current state analysis (based on your codebase research) + - Proposed improvements or new features + - Implementation strategy considering existing code +4. **Tag-Based Organization**: Parse PRDs into appropriate tags (`refactor-api`, `feature-dashboard`, `tech-debt`, etc.) +5. **Master List Curation**: Keep only the most valuable initiatives in master + +The parse-prd's `--append` flag enables the user to parse multiple PRDs within tags or across tags. PRDs should be focused and the number of tasks they are parsed into should be strategically chosen relative to the PRD's complexity and level of detail. + +### Workflow Transition Examples + +**Example 1: Simple → Team-Based** +``` +User: "Alice is going to help with the API work" +Your Response: "Great! To avoid conflicts, I'll create a separate task context for your work. Alice can continue with the master list while you work in your own context. When you're ready to merge, we can coordinate the tasks back together." +Action: add_tag my-api-work --copy-from-current --description="My API tasks while collaborating with Alice" +``` + +**Example 2: Simple → PRD-Driven** +``` +User: "I want to add a complete user dashboard with analytics, user management, and reporting" +Your Response: "This sounds like a major feature that would benefit from detailed planning. Let me create a dedicated context for this work and we can draft a PRD together to ensure we capture all requirements." +Actions: +1. add_tag feature-dashboard --description="User dashboard with analytics and management" +2. Collaborate on PRD creation +3. parse_prd dashboard-prd.txt --tag=feature-dashboard +4. Add high-level "User Dashboard" task to master +``` + +**Example 3: Existing Project → Strategic Planning** +``` +User: "I just initialized Taskmaster on my existing React app. It's getting messy and I want to improve it." +Your Response: "Let me research your codebase to understand the current architecture, then we can create a strategic plan for improvements." +Actions: +1. research "Current React app architecture and improvement opportunities" --tree --files=src/ +2. Collaborate on improvement PRD based on findings +3. Create tags for different improvement areas (refactor-components, improve-state-management, etc.) +4. Keep only major improvement initiatives in master +``` + +--- + +## Primary Interaction: MCP Server vs. CLI + +Taskmaster offers two primary ways to interact: + +1. **MCP Server (Recommended for Integrated Tools)**: + - For AI agents and integrated development environments (like Cursor), interacting via the **MCP server is the preferred method**. + - The MCP server exposes Taskmaster functionality through a set of tools (e.g., `get_tasks`, `add_subtask`). + - This method offers better performance, structured data exchange, and richer error handling compared to CLI parsing. + - Refer to @`mcp.mdc` for details on the MCP architecture and available tools. + - A comprehensive list and description of MCP tools and their corresponding CLI commands can be found in @`taskmaster.mdc`. + - **Restart the MCP server** if core logic in `scripts/modules` or MCP tool/direct function definitions change. + - **Note**: MCP tools fully support tagged task lists with complete tag management capabilities. + +2. **`task-master` CLI (For Users & Fallback)**: + - The global `task-master` command provides a user-friendly interface for direct terminal interaction. + - It can also serve as a fallback if the MCP server is inaccessible or a specific function isn't exposed via MCP. + - Install globally with `npm install -g task-master-ai` or use locally via `npx task-master-ai ...`. + - The CLI commands often mirror the MCP tools (e.g., `task-master list` corresponds to `get_tasks`). + - Refer to @`taskmaster.mdc` for a detailed command reference. + - **Tagged Task Lists**: CLI fully supports the new tagged system with seamless migration. + +## How the Tag System Works (For Your Reference) + +- **Data Structure**: Tasks are organized into separate contexts (tags) like "master", "feature-branch", or "v2.0". +- **Silent Migration**: Existing projects automatically migrate to use a "master" tag with zero disruption. +- **Context Isolation**: Tasks in different tags are completely separate. Changes in one tag do not affect any other tag. +- **Manual Control**: The user is always in control. There is no automatic switching. You facilitate switching by using `use-tag `. +- **Full CLI & MCP Support**: All tag management commands are available through both the CLI and MCP tools for you to use. Refer to @`taskmaster.mdc` for a full command list. + +--- + +## Task Complexity Analysis + +- Run `analyze_project_complexity` / `task-master analyze-complexity --research` (see @`taskmaster.mdc`) for comprehensive analysis +- Review complexity report via `complexity_report` / `task-master complexity-report` (see @`taskmaster.mdc`) for a formatted, readable version. +- Focus on tasks with highest complexity scores (8-10) for detailed breakdown +- Use analysis results to determine appropriate subtask allocation +- Note that reports are automatically used by the `expand_task` tool/command + +## Task Breakdown Process + +- Use `expand_task` / `task-master expand --id=`. It automatically uses the complexity report if found, otherwise generates default number of subtasks. +- Use `--num=` to specify an explicit number of subtasks, overriding defaults or complexity report recommendations. +- Add `--research` flag to leverage Perplexity AI for research-backed expansion. +- Add `--force` flag to clear existing subtasks before generating new ones (default is to append). +- Use `--prompt=""` to provide additional context when needed. +- Review and adjust generated subtasks as necessary. +- Use `expand_all` tool or `task-master expand --all` to expand multiple pending tasks at once, respecting flags like `--force` and `--research`. +- If subtasks need complete replacement (regardless of the `--force` flag on `expand`), clear them first with `clear_subtasks` / `task-master clear-subtasks --id=`. + +## Implementation Drift Handling + +- When implementation differs significantly from planned approach +- When future tasks need modification due to current implementation choices +- When new dependencies or requirements emerge +- Use `update` / `task-master update --from= --prompt='\nUpdate context...' --research` to update multiple future tasks. +- Use `update_task` / `task-master update-task --id= --prompt='\nUpdate context...' --research` to update a single specific task. + +## Task Status Management + +- Use 'pending' for tasks ready to be worked on +- Use 'done' for completed and verified tasks +- Use 'deferred' for postponed tasks +- Add custom status values as needed for project-specific workflows + +## Task Structure Fields + +- **id**: Unique identifier for the task (Example: `1`, `1.1`) +- **title**: Brief, descriptive title (Example: `"Initialize Repo"`) +- **description**: Concise summary of what the task involves (Example: `"Create a new repository, set up initial structure."`) +- **status**: Current state of the task (Example: `"pending"`, `"done"`, `"deferred"`) +- **dependencies**: IDs of prerequisite tasks (Example: `[1, 2.1]`) + - Dependencies are displayed with status indicators (✅ for completed, ⏱️ for pending) + - This helps quickly identify which prerequisite tasks are blocking work +- **priority**: Importance level (Example: `"high"`, `"medium"`, `"low"`) +- **details**: In-depth implementation instructions (Example: `"Use GitHub client ID/secret, handle callback, set session token."`) +- **testStrategy**: Verification approach (Example: `"Deploy and call endpoint to confirm 'Hello World' response."`) +- **subtasks**: List of smaller, more specific tasks (Example: `[{"id": 1, "title": "Configure OAuth", ...}]`) +- Refer to task structure details (previously linked to `tasks.mdc`). + +## Configuration Management (Updated) + +Taskmaster configuration is managed through two main mechanisms: + +1. **`.taskmaster/config.json` File (Primary):** + * Located in the project root directory. + * Stores most configuration settings: AI model selections (main, research, fallback), parameters (max tokens, temperature), logging level, default subtasks/priority, project name, etc. + * **Tagged System Settings**: Includes `global.defaultTag` (defaults to "master") and `tags` section for tag management configuration. + * **Managed via `task-master models --setup` command.** Do not edit manually unless you know what you are doing. + * **View/Set specific models via `task-master models` command or `models` MCP tool.** + * Created automatically when you run `task-master models --setup` for the first time or during tagged system migration. + +2. **Environment Variables (`.env` / `mcp.json`):** + * Used **only** for sensitive API keys and specific endpoint URLs. + * Place API keys (one per provider) in a `.env` file in the project root for CLI usage. + * For MCP/Cursor integration, configure these keys in the `env` section of `.cursor/mcp.json`. + * Available keys/variables: See `assets/env.example` or the Configuration section in the command reference (previously linked to `taskmaster.mdc`). + +3. **`.taskmaster/state.json` File (Tagged System State):** + * Tracks current tag context and migration status. + * Automatically created during tagged system migration. + * Contains: `currentTag`, `lastSwitched`, `migrationNoticeShown`. + +**Important:** Non-API key settings (like model selections, `MAX_TOKENS`, `TASKMASTER_LOG_LEVEL`) are **no longer configured via environment variables**. Use the `task-master models` command (or `--setup` for interactive configuration) or the `models` MCP tool. +**If AI commands FAIL in MCP** verify that the API key for the selected provider is present in the `env` section of `.cursor/mcp.json`. +**If AI commands FAIL in CLI** verify that the API key for the selected provider is present in the `.env` file in the root of the project. + +## Rules Management + +Taskmaster supports multiple AI coding assistant rule sets that can be configured during project initialization or managed afterward: + +- **Available Profiles**: Claude Code, Cline, Codex, Cursor, Roo Code, Trae, Windsurf (claude, cline, codex, cursor, roo, trae, windsurf) +- **During Initialization**: Use `task-master init --rules cursor,windsurf` to specify which rule sets to include +- **After Initialization**: Use `task-master rules add ` or `task-master rules remove ` to manage rule sets +- **Interactive Setup**: Use `task-master rules setup` to launch an interactive prompt for selecting rule profiles +- **Default Behavior**: If no `--rules` flag is specified during initialization, all available rule profiles are included +- **Rule Structure**: Each profile creates its own directory (e.g., `.cursor/rules`, `.roo/rules`) with appropriate configuration files + +## Determining the Next Task + +- Run `next_task` / `task-master next` to show the next task to work on. +- The command identifies tasks with all dependencies satisfied +- Tasks are prioritized by priority level, dependency count, and ID +- The command shows comprehensive task information including: + - Basic task details and description + - Implementation details + - Subtasks (if they exist) + - Contextual suggested actions +- Recommended before starting any new development work +- Respects your project's dependency structure +- Ensures tasks are completed in the appropriate sequence +- Provides ready-to-use commands for common task actions + +## Viewing Specific Task Details + +- Run `get_task` / `task-master show ` to view a specific task. +- Use dot notation for subtasks: `task-master show 1.2` (shows subtask 2 of task 1) +- Displays comprehensive information similar to the next command, but for a specific task +- For parent tasks, shows all subtasks and their current status +- For subtasks, shows parent task information and relationship +- Provides contextual suggested actions appropriate for the specific task +- Useful for examining task details before implementation or checking status + +## Managing Task Dependencies + +- Use `add_dependency` / `task-master add-dependency --id= --depends-on=` to add a dependency. +- Use `remove_dependency` / `task-master remove-dependency --id= --depends-on=` to remove a dependency. +- The system prevents circular dependencies and duplicate dependency entries +- Dependencies are checked for existence before being added or removed +- Task files are automatically regenerated after dependency changes +- Dependencies are visualized with status indicators in task listings and files + +## Task Reorganization + +- Use `move_task` / `task-master move --from= --to=` to move tasks or subtasks within the hierarchy +- This command supports several use cases: + - Moving a standalone task to become a subtask (e.g., `--from=5 --to=7`) + - Moving a subtask to become a standalone task (e.g., `--from=5.2 --to=7`) + - Moving a subtask to a different parent (e.g., `--from=5.2 --to=7.3`) + - Reordering subtasks within the same parent (e.g., `--from=5.2 --to=5.4`) + - Moving a task to a new, non-existent ID position (e.g., `--from=5 --to=25`) + - Moving multiple tasks at once using comma-separated IDs (e.g., `--from=10,11,12 --to=16,17,18`) +- The system includes validation to prevent data loss: + - Allows moving to non-existent IDs by creating placeholder tasks + - Prevents moving to existing task IDs that have content (to avoid overwriting) + - Validates source tasks exist before attempting to move them +- The system maintains proper parent-child relationships and dependency integrity +- Task files are automatically regenerated after the move operation +- This provides greater flexibility in organizing and refining your task structure as project understanding evolves +- This is especially useful when dealing with potential merge conflicts arising from teams creating tasks on separate branches. Solve these conflicts very easily by moving your tasks and keeping theirs. + +## Iterative Subtask Implementation + +Once a task has been broken down into subtasks using `expand_task` or similar methods, follow this iterative process for implementation: + +1. **Understand the Goal (Preparation):** + * Use `get_task` / `task-master show ` (see @`taskmaster.mdc`) to thoroughly understand the specific goals and requirements of the subtask. + +2. **Initial Exploration & Planning (Iteration 1):** + * This is the first attempt at creating a concrete implementation plan. + * Explore the codebase to identify the precise files, functions, and even specific lines of code that will need modification. + * Determine the intended code changes (diffs) and their locations. + * Gather *all* relevant details from this exploration phase. + +3. **Log the Plan:** + * Run `update_subtask` / `task-master update-subtask --id= --prompt=''`. + * Provide the *complete and detailed* findings from the exploration phase in the prompt. Include file paths, line numbers, proposed diffs, reasoning, and any potential challenges identified. Do not omit details. The goal is to create a rich, timestamped log within the subtask's `details`. + +4. **Verify the Plan:** + * Run `get_task` / `task-master show ` again to confirm that the detailed implementation plan has been successfully appended to the subtask's details. + +5. **Begin Implementation:** + * Set the subtask status using `set_task_status` / `task-master set-status --id= --status=in-progress`. + * Start coding based on the logged plan. + +6. **Refine and Log Progress (Iteration 2+):** + * As implementation progresses, you will encounter challenges, discover nuances, or confirm successful approaches. + * **Before appending new information**: Briefly review the *existing* details logged in the subtask (using `get_task` or recalling from context) to ensure the update adds fresh insights and avoids redundancy. + * **Regularly** use `update_subtask` / `task-master update-subtask --id= --prompt='\n- What worked...\n- What didn't work...'` to append new findings. + * **Crucially, log:** + * What worked ("fundamental truths" discovered). + * What didn't work and why (to avoid repeating mistakes). + * Specific code snippets or configurations that were successful. + * Decisions made, especially if confirmed with user input. + * Any deviations from the initial plan and the reasoning. + * The objective is to continuously enrich the subtask's details, creating a log of the implementation journey that helps the AI (and human developers) learn, adapt, and avoid repeating errors. + +7. **Review & Update Rules (Post-Implementation):** + * Once the implementation for the subtask is functionally complete, review all code changes and the relevant chat history. + * Identify any new or modified code patterns, conventions, or best practices established during the implementation. + * Create new or update existing rules following internal guidelines (previously linked to `cursor_rules.mdc` and `self_improve.mdc`). + +8. **Mark Task Complete:** + * After verifying the implementation and updating any necessary rules, mark the subtask as completed: `set_task_status` / `task-master set-status --id= --status=done`. + +9. **Commit Changes (If using Git):** + * Stage the relevant code changes and any updated/new rule files (`git add .`). + * Craft a comprehensive Git commit message summarizing the work done for the subtask, including both code implementation and any rule adjustments. + * Execute the commit command directly in the terminal (e.g., `git commit -m 'feat(module): Implement feature X for subtask \n\n- Details about changes...\n- Updated rule Y for pattern Z'`). + * Consider if a Changeset is needed according to internal versioning guidelines (previously linked to `changeset.mdc`). If so, run `npm run changeset`, stage the generated file, and amend the commit or create a new one. + +10. **Proceed to Next Subtask:** + * Identify the next subtask (e.g., using `next_task` / `task-master next`). + +## Code Analysis & Refactoring Techniques + +- **Top-Level Function Search**: + - Useful for understanding module structure or planning refactors. + - Use grep/ripgrep to find exported functions/constants: + `rg "export (async function|function|const) \w+"` or similar patterns. + - Can help compare functions between files during migrations or identify potential naming conflicts. + +--- +*This workflow provides a general guideline. Adapt it based on your specific project needs and team practices.* \ No newline at end of file diff --git a/.cursor/rules/taskmaster/taskmaster.mdc b/.cursor/rules/taskmaster/taskmaster.mdc new file mode 100644 index 0000000..3028467 --- /dev/null +++ b/.cursor/rules/taskmaster/taskmaster.mdc @@ -0,0 +1,558 @@ +--- +description: Comprehensive reference for Taskmaster MCP tools and CLI commands. +globs: **/* +alwaysApply: true +--- + +# Taskmaster Tool & Command Reference + +This document provides a detailed reference for interacting with Taskmaster, covering both the recommended MCP tools, suitable for integrations like Cursor, and the corresponding `task-master` CLI commands, designed for direct user interaction or fallback. + +**Note:** For interacting with Taskmaster programmatically or via integrated tools, using the **MCP tools is strongly recommended** due to better performance, structured data, and error handling. The CLI commands serve as a user-friendly alternative and fallback. + +**Important:** Several MCP tools involve AI processing... The AI-powered tools include `parse_prd`, `analyze_project_complexity`, `update_subtask`, `update_task`, `update`, `expand_all`, `expand_task`, and `add_task`. + +**🏷️ Tagged Task Lists System:** Task Master now supports **tagged task lists** for multi-context task management. This allows you to maintain separate, isolated lists of tasks for different features, branches, or experiments. Existing projects are seamlessly migrated to use a default "master" tag. Most commands now support a `--tag ` flag to specify which context to operate on. If omitted, commands use the currently active tag. + +--- + +## Initialization & Setup + +### 1. Initialize Project (`init`) + +* **MCP Tool:** `initialize_project` +* **CLI Command:** `task-master init [options]` +* **Description:** `Set up the basic Taskmaster file structure and configuration in the current directory for a new project.` +* **Key CLI Options:** + * `--name `: `Set the name for your project in Taskmaster's configuration.` + * `--description `: `Provide a brief description for your project.` + * `--version `: `Set the initial version for your project, e.g., '0.1.0'.` + * `-y, --yes`: `Initialize Taskmaster quickly using default settings without interactive prompts.` +* **Usage:** Run this once at the beginning of a new project. +* **MCP Variant Description:** `Set up the basic Taskmaster file structure and configuration in the current directory for a new project by running the 'task-master init' command.` +* **Key MCP Parameters/Options:** + * `projectName`: `Set the name for your project.` (CLI: `--name `) + * `projectDescription`: `Provide a brief description for your project.` (CLI: `--description `) + * `projectVersion`: `Set the initial version for your project, e.g., '0.1.0'.` (CLI: `--version `) + * `authorName`: `Author name.` (CLI: `--author `) + * `skipInstall`: `Skip installing dependencies. Default is false.` (CLI: `--skip-install`) + * `addAliases`: `Add shell aliases tm and taskmaster. Default is false.` (CLI: `--aliases`) + * `yes`: `Skip prompts and use defaults/provided arguments. Default is false.` (CLI: `-y, --yes`) +* **Usage:** Run this once at the beginning of a new project, typically via an integrated tool like Cursor. Operates on the current working directory of the MCP server. +* **Important:** Once complete, you *MUST* parse a prd in order to generate tasks. There will be no tasks files until then. The next step after initializing should be to create a PRD using the example PRD in .taskmaster/templates/example_prd.txt. +* **Tagging:** Use the `--tag` option to parse the PRD into a specific, non-default tag context. If the tag doesn't exist, it will be created automatically. Example: `task-master parse-prd spec.txt --tag=new-feature`. + +### 2. Parse PRD (`parse_prd`) + +* **MCP Tool:** `parse_prd` +* **CLI Command:** `task-master parse-prd [file] [options]` +* **Description:** `Parse a Product Requirements Document, PRD, or text file with Taskmaster to automatically generate an initial set of tasks in tasks.json.` +* **Key Parameters/Options:** + * `input`: `Path to your PRD or requirements text file that Taskmaster should parse for tasks.` (CLI: `[file]` positional or `-i, --input `) + * `output`: `Specify where Taskmaster should save the generated 'tasks.json' file. Defaults to '.taskmaster/tasks/tasks.json'.` (CLI: `-o, --output `) + * `numTasks`: `Approximate number of top-level tasks Taskmaster should aim to generate from the document.` (CLI: `-n, --num-tasks `) + * `force`: `Use this to allow Taskmaster to overwrite an existing 'tasks.json' without asking for confirmation.` (CLI: `-f, --force`) +* **Usage:** Useful for bootstrapping a project from an existing requirements document. +* **Notes:** Task Master will strictly adhere to any specific requirements mentioned in the PRD, such as libraries, database schemas, frameworks, tech stacks, etc., while filling in any gaps where the PRD isn't fully specified. Tasks are designed to provide the most direct implementation path while avoiding over-engineering. +* **Important:** This MCP tool makes AI calls and can take up to a minute to complete. Please inform users to hang tight while the operation is in progress. If the user does not have a PRD, suggest discussing their idea and then use the example PRD in `.taskmaster/templates/example_prd.txt` as a template for creating the PRD based on their idea, for use with `parse-prd`. + +--- + +## AI Model Configuration + +### 2. Manage Models (`models`) +* **MCP Tool:** `models` +* **CLI Command:** `task-master models [options]` +* **Description:** `View the current AI model configuration or set specific models for different roles (main, research, fallback). Allows setting custom model IDs for Ollama and OpenRouter.` +* **Key MCP Parameters/Options:** + * `setMain `: `Set the primary model ID for task generation/updates.` (CLI: `--set-main `) + * `setResearch `: `Set the model ID for research-backed operations.` (CLI: `--set-research `) + * `setFallback `: `Set the model ID to use if the primary fails.` (CLI: `--set-fallback `) + * `ollama `: `Indicates the set model ID is a custom Ollama model.` (CLI: `--ollama`) + * `openrouter `: `Indicates the set model ID is a custom OpenRouter model.` (CLI: `--openrouter`) + * `listAvailableModels `: `If true, lists available models not currently assigned to a role.` (CLI: No direct equivalent; CLI lists available automatically) + * `projectRoot `: `Optional. Absolute path to the project root directory.` (CLI: Determined automatically) +* **Key CLI Options:** + * `--set-main `: `Set the primary model.` + * `--set-research `: `Set the research model.` + * `--set-fallback `: `Set the fallback model.` + * `--ollama`: `Specify that the provided model ID is for Ollama (use with --set-*).` + * `--openrouter`: `Specify that the provided model ID is for OpenRouter (use with --set-*). Validates against OpenRouter API.` + * `--bedrock`: `Specify that the provided model ID is for AWS Bedrock (use with --set-*).` + * `--setup`: `Run interactive setup to configure models, including custom Ollama/OpenRouter IDs.` +* **Usage (MCP):** Call without set flags to get current config. Use `setMain`, `setResearch`, or `setFallback` with a valid model ID to update the configuration. Use `listAvailableModels: true` to get a list of unassigned models. To set a custom model, provide the model ID and set `ollama: true` or `openrouter: true`. +* **Usage (CLI):** Run without flags to view current configuration and available models. Use set flags to update specific roles. Use `--setup` for guided configuration, including custom models. To set a custom model via flags, use `--set-=` along with either `--ollama` or `--openrouter`. +* **Notes:** Configuration is stored in `.taskmaster/config.json` in the project root. This command/tool modifies that file. Use `listAvailableModels` or `task-master models` to see internally supported models. OpenRouter custom models are validated against their live API. Ollama custom models are not validated live. +* **API note:** API keys for selected AI providers (based on their model) need to exist in the mcp.json file to be accessible in MCP context. The API keys must be present in the local .env file for the CLI to be able to read them. +* **Model costs:** The costs in supported models are expressed in dollars. An input/output value of 3 is $3.00. A value of 0.8 is $0.80. +* **Warning:** DO NOT MANUALLY EDIT THE .taskmaster/config.json FILE. Use the included commands either in the MCP or CLI format as needed. Always prioritize MCP tools when available and use the CLI as a fallback. + +--- + +## Task Listing & Viewing + +### 3. Get Tasks (`get_tasks`) + +* **MCP Tool:** `get_tasks` +* **CLI Command:** `task-master list [options]` +* **Description:** `List your Taskmaster tasks, optionally filtering by status and showing subtasks.` +* **Key Parameters/Options:** + * `status`: `Show only Taskmaster tasks matching this status (or multiple statuses, comma-separated), e.g., 'pending' or 'done,in-progress'.` (CLI: `-s, --status `) + * `withSubtasks`: `Include subtasks indented under their parent tasks in the list.` (CLI: `--with-subtasks`) + * `tag`: `Specify which tag context to list tasks from. Defaults to the current active tag.` (CLI: `--tag `) + * `file`: `Path to your Taskmaster 'tasks.json' file. Default relies on auto-detection.` (CLI: `-f, --file `) +* **Usage:** Get an overview of the project status, often used at the start of a work session. + +### 4. Get Next Task (`next_task`) + +* **MCP Tool:** `next_task` +* **CLI Command:** `task-master next [options]` +* **Description:** `Ask Taskmaster to show the next available task you can work on, based on status and completed dependencies.` +* **Key Parameters/Options:** + * `file`: `Path to your Taskmaster 'tasks.json' file. Default relies on auto-detection.` (CLI: `-f, --file `) + * `tag`: `Specify which tag context to use. Defaults to the current active tag.` (CLI: `--tag `) +* **Usage:** Identify what to work on next according to the plan. + +### 5. Get Task Details (`get_task`) + +* **MCP Tool:** `get_task` +* **CLI Command:** `task-master show [id] [options]` +* **Description:** `Display detailed information for one or more specific Taskmaster tasks or subtasks by ID.` +* **Key Parameters/Options:** + * `id`: `Required. The ID of the Taskmaster task (e.g., '15'), subtask (e.g., '15.2'), or a comma-separated list of IDs ('1,5,10.2') you want to view.` (CLI: `[id]` positional or `-i, --id `) + * `tag`: `Specify which tag context to get the task(s) from. Defaults to the current active tag.` (CLI: `--tag `) + * `file`: `Path to your Taskmaster 'tasks.json' file. Default relies on auto-detection.` (CLI: `-f, --file `) +* **Usage:** Understand the full details for a specific task. When multiple IDs are provided, a summary table is shown. +* **CRITICAL INFORMATION** If you need to collect information from multiple tasks, use comma-separated IDs (i.e. 1,2,3) to receive an array of tasks. Do not needlessly get tasks one at a time if you need to get many as that is wasteful. + +--- + +## Task Creation & Modification + +### 6. Add Task (`add_task`) + +* **MCP Tool:** `add_task` +* **CLI Command:** `task-master add-task [options]` +* **Description:** `Add a new task to Taskmaster by describing it; AI will structure it.` +* **Key Parameters/Options:** + * `prompt`: `Required. Describe the new task you want Taskmaster to create, e.g., "Implement user authentication using JWT".` (CLI: `-p, --prompt `) + * `dependencies`: `Specify the IDs of any Taskmaster tasks that must be completed before this new one can start, e.g., '12,14'.` (CLI: `-d, --dependencies `) + * `priority`: `Set the priority for the new task: 'high', 'medium', or 'low'. Default is 'medium'.` (CLI: `--priority `) + * `research`: `Enable Taskmaster to use the research role for potentially more informed task creation.` (CLI: `-r, --research`) + * `tag`: `Specify which tag context to add the task to. Defaults to the current active tag.` (CLI: `--tag `) + * `file`: `Path to your Taskmaster 'tasks.json' file. Default relies on auto-detection.` (CLI: `-f, --file `) +* **Usage:** Quickly add newly identified tasks during development. +* **Important:** This MCP tool makes AI calls and can take up to a minute to complete. Please inform users to hang tight while the operation is in progress. + +### 7. Add Subtask (`add_subtask`) + +* **MCP Tool:** `add_subtask` +* **CLI Command:** `task-master add-subtask [options]` +* **Description:** `Add a new subtask to a Taskmaster parent task, or convert an existing task into a subtask.` +* **Key Parameters/Options:** + * `id` / `parent`: `Required. The ID of the Taskmaster task that will be the parent.` (MCP: `id`, CLI: `-p, --parent `) + * `taskId`: `Use this if you want to convert an existing top-level Taskmaster task into a subtask of the specified parent.` (CLI: `-i, --task-id `) + * `title`: `Required if not using taskId. The title for the new subtask Taskmaster should create.` (CLI: `-t, --title `) + * `description`: `A brief description for the new subtask.` (CLI: `-d, --description <text>`) + * `details`: `Provide implementation notes or details for the new subtask.` (CLI: `--details <text>`) + * `dependencies`: `Specify IDs of other tasks or subtasks, e.g., '15' or '16.1', that must be done before this new subtask.` (CLI: `--dependencies <ids>`) + * `status`: `Set the initial status for the new subtask. Default is 'pending'.` (CLI: `-s, --status <status>`) + * `skipGenerate`: `Prevent Taskmaster from automatically regenerating markdown task files after adding the subtask.` (CLI: `--skip-generate`) + * `tag`: `Specify which tag context to operate on. Defaults to the current active tag.` (CLI: `--tag <name>`) + * `file`: `Path to your Taskmaster 'tasks.json' file. Default relies on auto-detection.` (CLI: `-f, --file <file>`) +* **Usage:** Break down tasks manually or reorganize existing tasks. + +### 8. Update Tasks (`update`) + +* **MCP Tool:** `update` +* **CLI Command:** `task-master update [options]` +* **Description:** `Update multiple upcoming tasks in Taskmaster based on new context or changes, starting from a specific task ID.` +* **Key Parameters/Options:** + * `from`: `Required. The ID of the first task Taskmaster should update. All tasks with this ID or higher that are not 'done' will be considered.` (CLI: `--from <id>`) + * `prompt`: `Required. Explain the change or new context for Taskmaster to apply to the tasks, e.g., "We are now using React Query instead of Redux Toolkit for data fetching".` (CLI: `-p, --prompt <text>`) + * `research`: `Enable Taskmaster to use the research role for more informed updates. Requires appropriate API key.` (CLI: `-r, --research`) + * `tag`: `Specify which tag context to operate on. Defaults to the current active tag.` (CLI: `--tag <name>`) + * `file`: `Path to your Taskmaster 'tasks.json' file. Default relies on auto-detection.` (CLI: `-f, --file <file>`) +* **Usage:** Handle significant implementation changes or pivots that affect multiple future tasks. Example CLI: `task-master update --from='18' --prompt='Switching to React Query.\nNeed to refactor data fetching...'` +* **Important:** This MCP tool makes AI calls and can take up to a minute to complete. Please inform users to hang tight while the operation is in progress. + +### 9. Update Task (`update_task`) + +* **MCP Tool:** `update_task` +* **CLI Command:** `task-master update-task [options]` +* **Description:** `Modify a specific Taskmaster task by ID, incorporating new information or changes. By default, this replaces the existing task details.` +* **Key Parameters/Options:** + * `id`: `Required. The specific ID of the Taskmaster task, e.g., '15', you want to update.` (CLI: `-i, --id <id>`) + * `prompt`: `Required. Explain the specific changes or provide the new information Taskmaster should incorporate into this task.` (CLI: `-p, --prompt <text>`) + * `append`: `If true, appends the prompt content to the task's details with a timestamp, rather than replacing them. Behaves like update-subtask.` (CLI: `--append`) + * `research`: `Enable Taskmaster to use the research role for more informed updates. Requires appropriate API key.` (CLI: `-r, --research`) + * `tag`: `Specify which tag context the task belongs to. Defaults to the current active tag.` (CLI: `--tag <name>`) + * `file`: `Path to your Taskmaster 'tasks.json' file. Default relies on auto-detection.` (CLI: `-f, --file <file>`) +* **Usage:** Refine a specific task based on new understanding. Use `--append` to log progress without creating subtasks. +* **Important:** This MCP tool makes AI calls and can take up to a minute to complete. Please inform users to hang tight while the operation is in progress. + +### 10. Update Subtask (`update_subtask`) + +* **MCP Tool:** `update_subtask` +* **CLI Command:** `task-master update-subtask [options]` +* **Description:** `Append timestamped notes or details to a specific Taskmaster subtask without overwriting existing content. Intended for iterative implementation logging.` +* **Key Parameters/Options:** + * `id`: `Required. The ID of the Taskmaster subtask, e.g., '5.2', to update with new information.` (CLI: `-i, --id <id>`) + * `prompt`: `Required. The information, findings, or progress notes to append to the subtask's details with a timestamp.` (CLI: `-p, --prompt <text>`) + * `research`: `Enable Taskmaster to use the research role for more informed updates. Requires appropriate API key.` (CLI: `-r, --research`) + * `tag`: `Specify which tag context the subtask belongs to. Defaults to the current active tag.` (CLI: `--tag <name>`) + * `file`: `Path to your Taskmaster 'tasks.json' file. Default relies on auto-detection.` (CLI: `-f, --file <file>`) +* **Usage:** Log implementation progress, findings, and discoveries during subtask development. Each update is timestamped and appended to preserve the implementation journey. +* **Important:** This MCP tool makes AI calls and can take up to a minute to complete. Please inform users to hang tight while the operation is in progress. + +### 11. Set Task Status (`set_task_status`) + +* **MCP Tool:** `set_task_status` +* **CLI Command:** `task-master set-status [options]` +* **Description:** `Update the status of one or more Taskmaster tasks or subtasks, e.g., 'pending', 'in-progress', 'done'.` +* **Key Parameters/Options:** + * `id`: `Required. The ID(s) of the Taskmaster task(s) or subtask(s), e.g., '15', '15.2', or '16,17.1', to update.` (CLI: `-i, --id <id>`) + * `status`: `Required. The new status to set, e.g., 'done', 'pending', 'in-progress', 'review', 'cancelled'.` (CLI: `-s, --status <status>`) + * `tag`: `Specify which tag context to operate on. Defaults to the current active tag.` (CLI: `--tag <name>`) + * `file`: `Path to your Taskmaster 'tasks.json' file. Default relies on auto-detection.` (CLI: `-f, --file <file>`) +* **Usage:** Mark progress as tasks move through the development cycle. + +### 12. Remove Task (`remove_task`) + +* **MCP Tool:** `remove_task` +* **CLI Command:** `task-master remove-task [options]` +* **Description:** `Permanently remove a task or subtask from the Taskmaster tasks list.` +* **Key Parameters/Options:** + * `id`: `Required. The ID of the Taskmaster task, e.g., '5', or subtask, e.g., '5.2', to permanently remove.` (CLI: `-i, --id <id>`) + * `yes`: `Skip the confirmation prompt and immediately delete the task.` (CLI: `-y, --yes`) + * `tag`: `Specify which tag context to operate on. Defaults to the current active tag.` (CLI: `--tag <name>`) + * `file`: `Path to your Taskmaster 'tasks.json' file. Default relies on auto-detection.` (CLI: `-f, --file <file>`) +* **Usage:** Permanently delete tasks or subtasks that are no longer needed in the project. +* **Notes:** Use with caution as this operation cannot be undone. Consider using 'blocked', 'cancelled', or 'deferred' status instead if you just want to exclude a task from active planning but keep it for reference. The command automatically cleans up dependency references in other tasks. + +--- + +## Task Structure & Breakdown + +### 13. Expand Task (`expand_task`) + +* **MCP Tool:** `expand_task` +* **CLI Command:** `task-master expand [options]` +* **Description:** `Use Taskmaster's AI to break down a complex task into smaller, manageable subtasks. Appends subtasks by default.` +* **Key Parameters/Options:** + * `id`: `The ID of the specific Taskmaster task you want to break down into subtasks.` (CLI: `-i, --id <id>`) + * `num`: `Optional: Suggests how many subtasks Taskmaster should aim to create. Uses complexity analysis/defaults otherwise.` (CLI: `-n, --num <number>`) + * `research`: `Enable Taskmaster to use the research role for more informed subtask generation. Requires appropriate API key.` (CLI: `-r, --research`) + * `prompt`: `Optional: Provide extra context or specific instructions to Taskmaster for generating the subtasks.` (CLI: `-p, --prompt <text>`) + * `force`: `Optional: If true, clear existing subtasks before generating new ones. Default is false (append).` (CLI: `--force`) + * `tag`: `Specify which tag context the task belongs to. Defaults to the current active tag.` (CLI: `--tag <name>`) + * `file`: `Path to your Taskmaster 'tasks.json' file. Default relies on auto-detection.` (CLI: `-f, --file <file>`) +* **Usage:** Generate a detailed implementation plan for a complex task before starting coding. Automatically uses complexity report recommendations if available and `num` is not specified. +* **Important:** This MCP tool makes AI calls and can take up to a minute to complete. Please inform users to hang tight while the operation is in progress. + +### 14. Expand All Tasks (`expand_all`) + +* **MCP Tool:** `expand_all` +* **CLI Command:** `task-master expand --all [options]` (Note: CLI uses the `expand` command with the `--all` flag) +* **Description:** `Tell Taskmaster to automatically expand all eligible pending/in-progress tasks based on complexity analysis or defaults. Appends subtasks by default.` +* **Key Parameters/Options:** + * `num`: `Optional: Suggests how many subtasks Taskmaster should aim to create per task.` (CLI: `-n, --num <number>`) + * `research`: `Enable research role for more informed subtask generation. Requires appropriate API key.` (CLI: `-r, --research`) + * `prompt`: `Optional: Provide extra context for Taskmaster to apply generally during expansion.` (CLI: `-p, --prompt <text>`) + * `force`: `Optional: If true, clear existing subtasks before generating new ones for each eligible task. Default is false (append).` (CLI: `--force`) + * `tag`: `Specify which tag context to expand. Defaults to the current active tag.` (CLI: `--tag <name>`) + * `file`: `Path to your Taskmaster 'tasks.json' file. Default relies on auto-detection.` (CLI: `-f, --file <file>`) +* **Usage:** Useful after initial task generation or complexity analysis to break down multiple tasks at once. +* **Important:** This MCP tool makes AI calls and can take up to a minute to complete. Please inform users to hang tight while the operation is in progress. + +### 15. Clear Subtasks (`clear_subtasks`) + +* **MCP Tool:** `clear_subtasks` +* **CLI Command:** `task-master clear-subtasks [options]` +* **Description:** `Remove all subtasks from one or more specified Taskmaster parent tasks.` +* **Key Parameters/Options:** + * `id`: `The ID(s) of the Taskmaster parent task(s) whose subtasks you want to remove, e.g., '15' or '16,18'. Required unless using 'all'.` (CLI: `-i, --id <ids>`) + * `all`: `Tell Taskmaster to remove subtasks from all parent tasks.` (CLI: `--all`) + * `tag`: `Specify which tag context to operate on. Defaults to the current active tag.` (CLI: `--tag <name>`) + * `file`: `Path to your Taskmaster 'tasks.json' file. Default relies on auto-detection.` (CLI: `-f, --file <file>`) +* **Usage:** Used before regenerating subtasks with `expand_task` if the previous breakdown needs replacement. + +### 16. Remove Subtask (`remove_subtask`) + +* **MCP Tool:** `remove_subtask` +* **CLI Command:** `task-master remove-subtask [options]` +* **Description:** `Remove a subtask from its Taskmaster parent, optionally converting it into a standalone task.` +* **Key Parameters/Options:** + * `id`: `Required. The ID(s) of the Taskmaster subtask(s) to remove, e.g., '15.2' or '16.1,16.3'.` (CLI: `-i, --id <id>`) + * `convert`: `If used, Taskmaster will turn the subtask into a regular top-level task instead of deleting it.` (CLI: `-c, --convert`) + * `skipGenerate`: `Prevent Taskmaster from automatically regenerating markdown task files after removing the subtask.` (CLI: `--skip-generate`) + * `tag`: `Specify which tag context to operate on. Defaults to the current active tag.` (CLI: `--tag <name>`) + * `file`: `Path to your Taskmaster 'tasks.json' file. Default relies on auto-detection.` (CLI: `-f, --file <file>`) +* **Usage:** Delete unnecessary subtasks or promote a subtask to a top-level task. + +### 17. Move Task (`move_task`) + +* **MCP Tool:** `move_task` +* **CLI Command:** `task-master move [options]` +* **Description:** `Move a task or subtask to a new position within the task hierarchy.` +* **Key Parameters/Options:** + * `from`: `Required. ID of the task/subtask to move (e.g., "5" or "5.2"). Can be comma-separated for multiple tasks.` (CLI: `--from <id>`) + * `to`: `Required. ID of the destination (e.g., "7" or "7.3"). Must match the number of source IDs if comma-separated.` (CLI: `--to <id>`) + * `tag`: `Specify which tag context to operate on. Defaults to the current active tag.` (CLI: `--tag <name>`) + * `file`: `Path to your Taskmaster 'tasks.json' file. Default relies on auto-detection.` (CLI: `-f, --file <file>`) +* **Usage:** Reorganize tasks by moving them within the hierarchy. Supports various scenarios like: + * Moving a task to become a subtask + * Moving a subtask to become a standalone task + * Moving a subtask to a different parent + * Reordering subtasks within the same parent + * Moving a task to a new, non-existent ID (automatically creates placeholders) + * Moving multiple tasks at once with comma-separated IDs +* **Validation Features:** + * Allows moving tasks to non-existent destination IDs (creates placeholder tasks) + * Prevents moving to existing task IDs that already have content (to avoid overwriting) + * Validates that source tasks exist before attempting to move them + * Maintains proper parent-child relationships +* **Example CLI:** `task-master move --from=5.2 --to=7.3` to move subtask 5.2 to become subtask 7.3. +* **Example Multi-Move:** `task-master move --from=10,11,12 --to=16,17,18` to move multiple tasks to new positions. +* **Common Use:** Resolving merge conflicts in tasks.json when multiple team members create tasks on different branches. + +--- + +## Dependency Management + +### 18. Add Dependency (`add_dependency`) + +* **MCP Tool:** `add_dependency` +* **CLI Command:** `task-master add-dependency [options]` +* **Description:** `Define a dependency in Taskmaster, making one task a prerequisite for another.` +* **Key Parameters/Options:** + * `id`: `Required. The ID of the Taskmaster task that will depend on another.` (CLI: `-i, --id <id>`) + * `dependsOn`: `Required. The ID of the Taskmaster task that must be completed first, the prerequisite.` (CLI: `-d, --depends-on <id>`) + * `tag`: `Specify which tag context to operate on. Defaults to the current active tag.` (CLI: `--tag <name>`) + * `file`: `Path to your Taskmaster 'tasks.json' file. Default relies on auto-detection.` (CLI: `-f, --file <path>`) +* **Usage:** Establish the correct order of execution between tasks. + +### 19. Remove Dependency (`remove_dependency`) + +* **MCP Tool:** `remove_dependency` +* **CLI Command:** `task-master remove-dependency [options]` +* **Description:** `Remove a dependency relationship between two Taskmaster tasks.` +* **Key Parameters/Options:** + * `id`: `Required. The ID of the Taskmaster task you want to remove a prerequisite from.` (CLI: `-i, --id <id>`) + * `dependsOn`: `Required. The ID of the Taskmaster task that should no longer be a prerequisite.` (CLI: `-d, --depends-on <id>`) + * `tag`: `Specify which tag context to operate on. Defaults to the current active tag.` (CLI: `--tag <name>`) + * `file`: `Path to your Taskmaster 'tasks.json' file. Default relies on auto-detection.` (CLI: `-f, --file <file>`) +* **Usage:** Update task relationships when the order of execution changes. + +### 20. Validate Dependencies (`validate_dependencies`) + +* **MCP Tool:** `validate_dependencies` +* **CLI Command:** `task-master validate-dependencies [options]` +* **Description:** `Check your Taskmaster tasks for dependency issues (like circular references or links to non-existent tasks) without making changes.` +* **Key Parameters/Options:** + * `tag`: `Specify which tag context to validate. Defaults to the current active tag.` (CLI: `--tag <name>`) + * `file`: `Path to your Taskmaster 'tasks.json' file. Default relies on auto-detection.` (CLI: `-f, --file <file>`) +* **Usage:** Audit the integrity of your task dependencies. + +### 21. Fix Dependencies (`fix_dependencies`) + +* **MCP Tool:** `fix_dependencies` +* **CLI Command:** `task-master fix-dependencies [options]` +* **Description:** `Automatically fix dependency issues (like circular references or links to non-existent tasks) in your Taskmaster tasks.` +* **Key Parameters/Options:** + * `tag`: `Specify which tag context to fix dependencies in. Defaults to the current active tag.` (CLI: `--tag <name>`) + * `file`: `Path to your Taskmaster 'tasks.json' file. Default relies on auto-detection.` (CLI: `-f, --file <file>`) +* **Usage:** Clean up dependency errors automatically. + +--- + +## Analysis & Reporting + +### 22. Analyze Project Complexity (`analyze_project_complexity`) + +* **MCP Tool:** `analyze_project_complexity` +* **CLI Command:** `task-master analyze-complexity [options]` +* **Description:** `Have Taskmaster analyze your tasks to determine their complexity and suggest which ones need to be broken down further.` +* **Key Parameters/Options:** + * `output`: `Where to save the complexity analysis report. Default is '.taskmaster/reports/task-complexity-report.json' (or '..._tagname.json' if a tag is used).` (CLI: `-o, --output <file>`) + * `threshold`: `The minimum complexity score (1-10) that should trigger a recommendation to expand a task.` (CLI: `-t, --threshold <number>`) + * `research`: `Enable research role for more accurate complexity analysis. Requires appropriate API key.` (CLI: `-r, --research`) + * `tag`: `Specify which tag context to analyze. Defaults to the current active tag.` (CLI: `--tag <name>`) + * `file`: `Path to your Taskmaster 'tasks.json' file. Default relies on auto-detection.` (CLI: `-f, --file <file>`) +* **Usage:** Used before breaking down tasks to identify which ones need the most attention. +* **Important:** This MCP tool makes AI calls and can take up to a minute to complete. Please inform users to hang tight while the operation is in progress. + +### 23. View Complexity Report (`complexity_report`) + +* **MCP Tool:** `complexity_report` +* **CLI Command:** `task-master complexity-report [options]` +* **Description:** `Display the task complexity analysis report in a readable format.` +* **Key Parameters/Options:** + * `tag`: `Specify which tag context to show the report for. Defaults to the current active tag.` (CLI: `--tag <name>`) + * `file`: `Path to the complexity report (default: '.taskmaster/reports/task-complexity-report.json').` (CLI: `-f, --file <file>`) +* **Usage:** Review and understand the complexity analysis results after running analyze-complexity. + +--- + +## File Management + +### 24. Generate Task Files (`generate`) + +* **MCP Tool:** `generate` +* **CLI Command:** `task-master generate [options]` +* **Description:** `Create or update individual Markdown files for each task based on your tasks.json.` +* **Key Parameters/Options:** + * `output`: `The directory where Taskmaster should save the task files (default: in a 'tasks' directory).` (CLI: `-o, --output <directory>`) + * `tag`: `Specify which tag context to generate files for. Defaults to the current active tag.` (CLI: `--tag <name>`) + * `file`: `Path to your Taskmaster 'tasks.json' file. Default relies on auto-detection.` (CLI: `-f, --file <file>`) +* **Usage:** Run this after making changes to tasks.json to keep individual task files up to date. This command is now manual and no longer runs automatically. + +--- + +## AI-Powered Research + +### 25. Research (`research`) + +* **MCP Tool:** `research` +* **CLI Command:** `task-master research [options]` +* **Description:** `Perform AI-powered research queries with project context to get fresh, up-to-date information beyond the AI's knowledge cutoff.` +* **Key Parameters/Options:** + * `query`: `Required. Research query/prompt (e.g., "What are the latest best practices for React Query v5?").` (CLI: `[query]` positional or `-q, --query <text>`) + * `taskIds`: `Comma-separated list of task/subtask IDs from the current tag context (e.g., "15,16.2,17").` (CLI: `-i, --id <ids>`) + * `filePaths`: `Comma-separated list of file paths for context (e.g., "src/api.js,docs/readme.md").` (CLI: `-f, --files <paths>`) + * `customContext`: `Additional custom context text to include in the research.` (CLI: `-c, --context <text>`) + * `includeProjectTree`: `Include project file tree structure in context (default: false).` (CLI: `--tree`) + * `detailLevel`: `Detail level for the research response: 'low', 'medium', 'high' (default: medium).` (CLI: `--detail <level>`) + * `saveTo`: `Task or subtask ID (e.g., "15", "15.2") to automatically save the research conversation to.` (CLI: `--save-to <id>`) + * `saveFile`: `If true, saves the research conversation to a markdown file in '.taskmaster/docs/research/'.` (CLI: `--save-file`) + * `noFollowup`: `Disables the interactive follow-up question menu in the CLI.` (CLI: `--no-followup`) + * `tag`: `Specify which tag context to use for task-based context gathering. Defaults to the current active tag.` (CLI: `--tag <name>`) + * `projectRoot`: `The directory of the project. Must be an absolute path.` (CLI: Determined automatically) +* **Usage:** **This is a POWERFUL tool that agents should use FREQUENTLY** to: + * Get fresh information beyond knowledge cutoff dates + * Research latest best practices, library updates, security patches + * Find implementation examples for specific technologies + * Validate approaches against current industry standards + * Get contextual advice based on project files and tasks +* **When to Consider Using Research:** + * **Before implementing any task** - Research current best practices + * **When encountering new technologies** - Get up-to-date implementation guidance (libraries, apis, etc) + * **For security-related tasks** - Find latest security recommendations + * **When updating dependencies** - Research breaking changes and migration guides + * **For performance optimization** - Get current performance best practices + * **When debugging complex issues** - Research known solutions and workarounds +* **Research + Action Pattern:** + * Use `research` to gather fresh information + * Use `update_subtask` to commit findings with timestamps + * Use `update_task` to incorporate research into task details + * Use `add_task` with research flag for informed task creation +* **Important:** This MCP tool makes AI calls and can take up to a minute to complete. The research provides FRESH data beyond the AI's training cutoff, making it invaluable for current best practices and recent developments. + +--- + +## Tag Management + +This new suite of commands allows you to manage different task contexts (tags). + +### 26. List Tags (`tags`) + +* **MCP Tool:** `list_tags` +* **CLI Command:** `task-master tags [options]` +* **Description:** `List all available tags with task counts, completion status, and other metadata.` +* **Key Parameters/Options:** + * `file`: `Path to your Taskmaster 'tasks.json' file. Default relies on auto-detection.` (CLI: `-f, --file <file>`) + * `--show-metadata`: `Include detailed metadata in the output (e.g., creation date, description).` (CLI: `--show-metadata`) + +### 27. Add Tag (`add_tag`) + +* **MCP Tool:** `add_tag` +* **CLI Command:** `task-master add-tag <tagName> [options]` +* **Description:** `Create a new, empty tag context, or copy tasks from another tag.` +* **Key Parameters/Options:** + * `tagName`: `Name of the new tag to create (alphanumeric, hyphens, underscores).` (CLI: `<tagName>` positional) + * `--from-branch`: `Creates a tag with a name derived from the current git branch, ignoring the <tagName> argument.` (CLI: `--from-branch`) + * `--copy-from-current`: `Copy tasks from the currently active tag to the new tag.` (CLI: `--copy-from-current`) + * `--copy-from <tag>`: `Copy tasks from a specific source tag to the new tag.` (CLI: `--copy-from <tag>`) + * `--description <text>`: `Provide an optional description for the new tag.` (CLI: `-d, --description <text>`) + * `file`: `Path to your Taskmaster 'tasks.json' file. Default relies on auto-detection.` (CLI: `-f, --file <file>`) + +### 28. Delete Tag (`delete_tag`) + +* **MCP Tool:** `delete_tag` +* **CLI Command:** `task-master delete-tag <tagName> [options]` +* **Description:** `Permanently delete a tag and all of its associated tasks.` +* **Key Parameters/Options:** + * `tagName`: `Name of the tag to delete.` (CLI: `<tagName>` positional) + * `--yes`: `Skip the confirmation prompt.` (CLI: `-y, --yes`) + * `file`: `Path to your Taskmaster 'tasks.json' file. Default relies on auto-detection.` (CLI: `-f, --file <file>`) + +### 29. Use Tag (`use_tag`) + +* **MCP Tool:** `use_tag` +* **CLI Command:** `task-master use-tag <tagName>` +* **Description:** `Switch your active task context to a different tag.` +* **Key Parameters/Options:** + * `tagName`: `Name of the tag to switch to.` (CLI: `<tagName>` positional) + * `file`: `Path to your Taskmaster 'tasks.json' file. Default relies on auto-detection.` (CLI: `-f, --file <file>`) + +### 30. Rename Tag (`rename_tag`) + +* **MCP Tool:** `rename_tag` +* **CLI Command:** `task-master rename-tag <oldName> <newName>` +* **Description:** `Rename an existing tag.` +* **Key Parameters/Options:** + * `oldName`: `The current name of the tag.` (CLI: `<oldName>` positional) + * `newName`: `The new name for the tag.` (CLI: `<newName>` positional) + * `file`: `Path to your Taskmaster 'tasks.json' file. Default relies on auto-detection.` (CLI: `-f, --file <file>`) + +### 31. Copy Tag (`copy_tag`) + +* **MCP Tool:** `copy_tag` +* **CLI Command:** `task-master copy-tag <sourceName> <targetName> [options]` +* **Description:** `Copy an entire tag context, including all its tasks and metadata, to a new tag.` +* **Key Parameters/Options:** + * `sourceName`: `Name of the tag to copy from.` (CLI: `<sourceName>` positional) + * `targetName`: `Name of the new tag to create.` (CLI: `<targetName>` positional) + * `--description <text>`: `Optional description for the new tag.` (CLI: `-d, --description <text>`) + +--- + +## Miscellaneous + +### 32. Sync Readme (`sync-readme`) -- experimental + +* **MCP Tool:** N/A +* **CLI Command:** `task-master sync-readme [options]` +* **Description:** `Exports your task list to your project's README.md file, useful for showcasing progress.` +* **Key Parameters/Options:** + * `status`: `Filter tasks by status (e.g., 'pending', 'done').` (CLI: `-s, --status <status>`) + * `withSubtasks`: `Include subtasks in the export.` (CLI: `--with-subtasks`) + * `tag`: `Specify which tag context to export from. Defaults to the current active tag.` (CLI: `--tag <name>`) + +--- + +## Environment Variables Configuration (Updated) + +Taskmaster primarily uses the **`.taskmaster/config.json`** file (in project root) for configuration (models, parameters, logging level, etc.), managed via `task-master models --setup`. + +Environment variables are used **only** for sensitive API keys related to AI providers and specific overrides like the Ollama base URL: + +* **API Keys (Required for corresponding provider):** + * `ANTHROPIC_API_KEY` + * `PERPLEXITY_API_KEY` + * `OPENAI_API_KEY` + * `GOOGLE_API_KEY` + * `MISTRAL_API_KEY` + * `AZURE_OPENAI_API_KEY` (Requires `AZURE_OPENAI_ENDPOINT` too) + * `OPENROUTER_API_KEY` + * `XAI_API_KEY` + * `OLLAMA_API_KEY` (Requires `OLLAMA_BASE_URL` too) +* **Endpoints (Optional/Provider Specific inside .taskmaster/config.json):** + * `AZURE_OPENAI_ENDPOINT` + * `OLLAMA_BASE_URL` (Default: `http://localhost:11434/api`) + +**Set API keys** in your **`.env`** file in the project root (for CLI use) or within the `env` section of your **`.cursor/mcp.json`** file (for MCP/Cursor integration). All other settings (model choice, max tokens, temperature, log level, custom endpoints) are managed in `.taskmaster/config.json` via `task-master models` command or `models` MCP tool. + +--- + +For details on how these commands fit into the development process, see the [dev_workflow.mdc](mdc:.cursor/rules/taskmaster/dev_workflow.mdc). \ No newline at end of file diff --git a/.gitignore b/.gitignore index 9438eb3..62ad778 100644 --- a/.gitignore +++ b/.gitignore @@ -59,4 +59,24 @@ html2slim.* *.csv -public/uploads/* \ No newline at end of file +public/uploads/* + +npm-debug.log* +yarn-debug.log* +yarn-error.log* +dev-debug.log + +# Environment variables +# Editor directories and files +.idea +.vscode +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? +# OS specific + +# Task files +# tasks.json +# tasks/ diff --git a/.taskmaster/config.json b/.taskmaster/config.json new file mode 100644 index 0000000..1361bc1 --- /dev/null +++ b/.taskmaster/config.json @@ -0,0 +1,34 @@ +{ + "models": { + "main": { + "provider": "anthropic", + "modelId": "claude-3-7-sonnet-20250219", + "maxTokens": 120000, + "temperature": 0.2 + }, + "research": { + "provider": "perplexity", + "modelId": "sonar-pro", + "maxTokens": 8700, + "temperature": 0.1 + }, + "fallback": { + "provider": "anthropic", + "modelId": "claude-3-7-sonnet-20250219", + "maxTokens": 120000, + "temperature": 0.2 + } + }, + "global": { + "logLevel": "info", + "debug": false, + "defaultSubtasks": 5, + "defaultPriority": "medium", + "projectName": "Taskmaster", + "defaultTag": "master", + "ollamaBaseURL": "http://localhost:11434/api", + "azureOpenaiBaseURL": "https://your-endpoint.openai.azure.com/", + "bedrockBaseURL": "https://bedrock.us-east-1.amazonaws.com", + "responseLanguage": "English" + } +} \ No newline at end of file diff --git a/.taskmaster/state.json b/.taskmaster/state.json new file mode 100644 index 0000000..eb2b750 --- /dev/null +++ b/.taskmaster/state.json @@ -0,0 +1,6 @@ +{ + "currentTag": "master", + "lastSwitched": "2025-07-12T21:33:58.498Z", + "branchTagMapping": {}, + "migrationNoticeShown": false +} \ No newline at end of file diff --git a/.taskmaster/templates/example_prd.txt b/.taskmaster/templates/example_prd.txt new file mode 100644 index 0000000..194114d --- /dev/null +++ b/.taskmaster/templates/example_prd.txt @@ -0,0 +1,47 @@ +<context> +# Overview +[Provide a high-level overview of your product here. Explain what problem it solves, who it's for, and why it's valuable.] + +# Core Features +[List and describe the main features of your product. For each feature, include: +- What it does +- Why it's important +- How it works at a high level] + +# User Experience +[Describe the user journey and experience. Include: +- User personas +- Key user flows +- UI/UX considerations] +</context> +<PRD> +# Technical Architecture +[Outline the technical implementation details: +- System components +- Data models +- APIs and integrations +- Infrastructure requirements] + +# Development Roadmap +[Break down the development process into phases: +- MVP requirements +- Future enhancements +- Do not think about timelines whatsoever -- all that matters is scope and detailing exactly what needs to be build in each phase so it can later be cut up into tasks] + +# Logical Dependency Chain +[Define the logical order of development: +- Which features need to be built first (foundation) +- Getting as quickly as possible to something usable/visible front end that works +- Properly pacing and scoping each feature so it is atomic but can also be built upon and improved as development approaches] + +# Risks and Mitigations +[Identify potential risks and how they'll be addressed: +- Technical challenges +- Figuring out the MVP that we can build upon +- Resource constraints] + +# Appendix +[Include any additional information: +- Research findings +- Technical specifications] +</PRD> \ No newline at end of file diff --git a/DOCS.md b/DOCS.md new file mode 100644 index 0000000..e32b32d --- /dev/null +++ b/DOCS.md @@ -0,0 +1,51 @@ +# Claudy + +## Finalité de l'application + +Cette application est principalement une plateforme de gestion de séjours. Elle permet de créer et suivre des séjours (Stay) pour des clients, incluant divers éléments comme des hébergements, des chambres, des lits, des espaces, des expériences, des produits, ou encore des objets de location. L’objectif est de faciliter l’administration de réservations complexes composées de multiples éléments, tout en assurant une bonne traçabilité (dates, paiements, etc.). + +## Fonctionnalités principales + +### Gestion des séjours (Stay) + +- Création de séjours avec ou sans client associé +- Ajout dynamique d’éléments (StayItem) à un séjour (lit, chambre, produit…) +- Attribution de dates précises aux éléments via StayItemDate + +### Modèle polymorphe StayItem + +- Lien entre un séjour et des entités polymorphiques (Room, Bed, RentalItem, etc.). +- Formulaires interactifs avec Hotwire + - Utilisation de Turbo Frames et Turbo Streams pour ajouter des éléments au séjour sans recharger la page. + - Ouverture de modales pour sélectionner des objets à associer au séjour. + +### Gestion des clients (Customer) + +- Création via formulaire imbriqué (accepts_nested_attributes_for) + +### Affichage et organisation + +- Tri personnalisé des éléments du séjour par type (ex. : Space, puis Lodging, Room, etc.) +- Affichage sous forme de tableau avec actions (supprimer, modifier…) + +### Support de la planification + +- Attribution de dates précises à chaque StayItem + +### Paiements + +- Les séjours peuvent avoir des paiements associés (Payment), fonctionnalité à développer ou étendre +- Paiements en ligne via Stripe + +## Technologies et conventions utilisées + +- Ruby on Rails (version 7+) +- Hotwire : Turbo Frames, Turbo Streams, StimulusJS pour les interactions dynamiques (ex. déclenchement de contrôleurs au changement de date) +- Vite + vite-rails pour la gestion des assets JS/CSS +- PostgreSQL comme base de données +- puma-dev en environnement local (ex. : http://vite.claudy.test) +- TailwindCSS pour le design +- ViewComponent (optionnel : à confirmer si utilisé) +- Decoration avec Draper : utilisation de @stay.stay_items.decorate +- CSP (Content Security Policy) : renforcée, nécessite ajout explicite des URLs ws:// et http:// pour Vite en développement. +- Polymorphic associations : StayItem utilise belongs_to :item, polymorphic: true \ No newline at end of file diff --git a/MIGRATION_BOOKINGS_TO_STAYS.md b/MIGRATION_BOOKINGS_TO_STAYS.md new file mode 100644 index 0000000..6566463 --- /dev/null +++ b/MIGRATION_BOOKINGS_TO_STAYS.md @@ -0,0 +1,196 @@ +# Migration des Bookings vers Stays + +Ce document explique comment migrer les données des anciens **Bookings** vers les nouveaux modèles **Stays** et **Customers**. + +## 📋 Contexte + +En production, les modèles `Stay` et `Customer` n'existent pas encore. Cette migration permettra de : + +1. **Créer des Customers** basés sur les emails des bookings existants +2. **Créer des Stays** avec tous les attributs appropriés +3. **Créer des StayItems** pour les hébergements (lodgings) +4. **Créer des Payments** si des informations de paiement existent +5. **Gérer le rollback** automatique en cas d'erreur + +## 🗂️ Mapping des attributs + +### Booking → Customer +**Un customer est créé pour chaque booking** : +- Si `email` est présent → Recherche d'un customer existant ou création d'un nouveau +- Si `email` est vide → Création d'un nouveau customer avec email vide +- `firstname` → `customer.firstname` +- `lastname` → `customer.lastname` +- `phone` → `customer.phone` +- `created_at` et `updated_at` → Préservés + +### Booking → Stay +- `user_id` → `1` (utilisateur par défaut pour la migration) +- `id` → `stay.legacy_booking_id` (référence vers l'ancien booking) +- `from_date` → `stay.start_date` +- `to_date` → `stay.end_date` +- `status` → `stay.status` +- `adults` → `stay.adults` +- `children` → `stay.children` +- `babies` → `stay.babies` +- `payment_status` → `stay.payment_status` (calculé après création des payments) +- `notes` → `stay.notes` +- `created_at` et `updated_at` → Préservés +- `invoice_status` → `stay.invoice_status` +- `group_name` → `stay.group_name` +- `estimated_arrival` → `stay.estimated_arrival` +- `departure_time` → `stay.departure_time` +- `comments` → `stay.comments` +- `token` → `stay.token` +- `platform` → `stay.platform` +- `public_notes` → `stay.public_notes` +- `deleted_at` → `stay.deleted_at` +- `price_cents` → `stay.final_price_cents` + +### Booking → StayItem +**Logique de priorité** : Si `lodging_id` est présent, seul le lodging est créé (pas de StayItems pour les rooms). + +**Pour les hébergements complets (lodgings)** : +Si `lodging_id` est présent : +- Création d'un `StayItem` de type `Lodging` +- `lodging_id` → `stay_item.item_id` +- `from_date` et `to_date` → `stay_item.start_date` et `stay_item.end_date` +- **Les réservations de rooms sont ignorées** (car le lodging complet est réservé) + +**Pour les chambres individuelles (rooms)** : +Si le booking a des `reservations` ET `lodging_id` est vide : +- Création d'un `StayItem` de type `Room` pour chaque chambre réservée +- Groupement des réservations par `room_id` +- Les dates utilisées sont celles du booking (pas calculées depuis les réservations individuelles) +- `room_id` → `stay_item.item_id` +- `from_date` et `to_date` du booking → `stay_item.start_date` et `stay_item.end_date` + +*Note : Les réservations dans la DB sont des enregistrements individuels pour chaque nuit, mais les StayItems utilisent les dates globales du booking.* + +### Booking → Payment +Si `payment_status` et `price_cents` sont présents : +- `price_cents` → `payment.amount_cents` +- `payment_method` → `payment.payment_method` (défaut: 'cash' si vide) +- `payment_status` → `payment.status` (mappé appropriément) + +## 🚀 Instructions d'utilisation + +### 0. Migration préalable (OBLIGATOIRE) + +Avant tout, il faut exécuter la migration Rails pour ajouter la colonne `legacy_booking_id` : + +```bash +rails db:migrate +``` + +Cette migration ajoute la colonne `legacy_booking_id` à la table `stays` avec un index pour les performances. + +### 1. Vérification préalable + +Avant de lancer la migration, vérifiez l'état actuel : + +```bash +rake migration:check_migration_status +``` + +Cette tâche affiche : +- Le nombre de bookings, stays et customers actuels +- Les bookings avec des données manquantes +- Une estimation du nombre de customers qui seront créés + +### 2. Lancement de la migration + +```bash +rake migration:migrate_bookings_to_stays +``` + +⚠️ **IMPORTANT** : Cette tâche doit être lancée dans un environnement où vous pouvez surveiller les logs. + +### 3. Vérification post-migration + +```bash +rake migration:migration_report +``` + +Cette tâche affiche un rapport complet sur les données migrées. + +## 🔧 Gestion des erreurs + +### Rollback automatique + +Si une erreur survient pendant la migration : +1. **Tous les stays créés** seront automatiquement supprimés +2. **Tous les nouveaux customers** (sans stays restants) seront supprimés +3. **La transaction sera annulée** complètement +4. **Un message d'erreur détaillé** sera affiché + +### Nettoyage manuel (si nécessaire) + +⚠️ **DANGER** : Cette tâche supprime TOUTES les données migrées ! + +```bash +rake migration:clean_migrated_data +``` + +## 📊 Attributs non migrés + +Les attributs suivants des bookings ne sont **pas** migrés : +- `bedsheets` +- `towels` +- `contract_status` +- `option_babysitting` +- `option_partyhall` +- `option_bread` +- `tier` +- `option_discgolf` +- `show_price_cents` +- `option_pizza_party` +- `wifi` + +## 🔍 Points d'attention + +### Bookings sans email +Les bookings sans email seront migrés avec un customer créé ayant un email vide. Chaque booking aura toujours un customer associé. + +### Bookings sans dates +Les bookings sans `from_date` ou `to_date` causeront une erreur et interrompront la migration. Il faut les corriger avant de relancer. + +### Tokens en doublon +Si des tokens existent déjà, la migration peut échouer. Dans ce cas, il faudra soit : +- Nettoyer les stays existants +- Modifier la logique de génération de token + +### Customers existants +Si un customer avec le même email existe déjà, il sera réutilisé (pas de duplication). + +### Référence legacy_booking_id +Chaque stay aura un attribut `legacy_booking_id` qui contient l'ID de l'ancien booking. Cela permet de maintenir une traçabilité entre les anciennes et nouvelles données. + +## 📈 Exemple d'utilisation complète + +```bash +# 0. Exécuter la migration Rails (OBLIGATOIRE) +rails db:migrate + +# 1. Vérifier l'état initial +rake migration:check_migration_status + +# 2. Lancer la migration +rake migration:migrate_bookings_to_stays + +# 3. Vérifier le résultat +rake migration:migration_report + +# 4. Si problème, nettoyer (optionnel) +# rake migration:clean_migrated_data +``` + +## 🔐 Sécurité + +- **Transaction atomique** : Tout ou rien +- **Préservation des timestamps** : Les dates de création/modification originales sont conservées +- **Rollback automatique** : En cas d'erreur, tout est annulé +- **Logs détaillés** : Chaque étape est tracée + +--- + +💡 **Conseil** : Testez d'abord sur un environnement de développement avec une copie des données de production ! \ No newline at end of file diff --git a/README.md b/README.md index 648b98c..58b0276 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,6 @@ # Claudy -Claudy is the in-house web application for Les 4 Sources, a foundation whose activities are run -by a collective of families living in Yvoir, Belgium, at the Domaine d'Ahinvaux. +Claudy is the in-house web application for Les 4 Sources, a foundation whose activities are run by a collective of families living in Yvoir, Belgium, at the Domaine d'Ahinvaux. [Read more](https://github.com/les4sources/claudy/wiki) in the wiki. It is built on Ruby on Rails 7 and PostgreSQL. @@ -13,14 +12,11 @@ See [.ruby-version](https://github.com/les4sources/claudy/blob/main/.ruby-versio ## Tests -There are no tests for now, but we want to introduce a test suite with the upcoming Spaces -refactoring (see [#8](https://github.com/les4sources/claudy/issues/8)). Feel free to start -using any test system that you feel comfortable with. +There are no tests for now. We only have a few users and we know each other. If there is a bug, they'll knock at my door! ## Deployment -We deploy on a Akamai/Linode 2GB using Hatchbox. We might set a staging environment up on the same VPS -once we start working collectively on the code. +We deploy on a Akamai/Linode 2GB using Hatchbox. We might set a staging environment up on the same VPS once we start working collectively on the code. ## Quick Start diff --git a/app/components/turbo_modal/component.rb b/app/components/turbo_modal/component.rb index 459dd73..3e5b006 100644 --- a/app/components/turbo_modal/component.rb +++ b/app/components/turbo_modal/component.rb @@ -17,6 +17,8 @@ def set_modal_classes(width) case width when :sm "sm:w-full sm:max-w-sm" + when :md + "sm:w-full md:max-w-md" when :lg "sm:max-w-5xl sm:w-full" end diff --git a/app/controllers/accounting_controller.rb b/app/controllers/accounting_controller.rb index aa572cd..f7bd6fb 100644 --- a/app/controllers/accounting_controller.rb +++ b/app/controllers/accounting_controller.rb @@ -4,18 +4,16 @@ def index .decorate_collection( Booking.where(tier: "non défini", status: ["confirmed", "pending"]) ) - @bookings_with_requested_invoice = BookingDecorator - .decorate_collection( - Booking.where(status: "confirmed", invoice_status: "requested") - ) @space_bookings = SpaceBookingDecorator .decorate_collection( SpaceBooking.where(tier: "non défini", status: ["confirmed", "pending"]) ) - @space_bookings_with_requested_invoice = SpaceBookingDecorator - .decorate_collection( - SpaceBooking.where(status: "confirmed", invoice_status: "requested") - ) + @stays_without_price = StayDecorator.decorate_collection( + Stay.where(status: "confirmed", final_price_cents: 0, draft: false) + ) + @stays_with_requested_invoice = StayDecorator.decorate_collection( + Stay.where(status: "confirmed", invoice_status: "requested", draft: false) + ) end private diff --git a/app/controllers/customers_controller.rb b/app/controllers/customers_controller.rb new file mode 100644 index 0000000..3c38cf5 --- /dev/null +++ b/app/controllers/customers_controller.rb @@ -0,0 +1,123 @@ +class CustomersController < BaseController + before_action :get_customer, only: [:show, :edit, :update] + + breadcrumb "Clients", :customers_path, match: :exact + + def index + letter = params[:letter].presence || 'A' + @letters = ('A'..'Z').to_a + @current_letter = letter + + # On sélectionne tous les clients dont le nom d'affichage commence par la lettre + @customers = Customer.where( + "(company_name IS NOT NULL AND company_name != '' AND UPPER(SUBSTR(company_name, 1, 1)) = ?) OR (company_name IS NULL OR company_name = '') AND UPPER(SUBSTR(lastname, 1, 1)) = ?", + letter, letter + ) + + # On trie par nom d'affichage (company_name si présent, sinon lastname + firstname) + @customers = @customers.sort_by do |c| + if c.company_name.present? + c.company_name.downcase + else + "#{c.lastname} #{c.firstname}".downcase + end + end + + @customers = CustomerDecorator.decorate_collection(@customers) + end + + def show + @customer = @customer.decorate + end + + def new + @customer = Customer.new + end + + def create + service = Customers::CreateService.new + if service.run(params) + redirect_to customer_path(service.customer), + notice: "Super! Le client a été ajouté." + else + @customer = service.customer + set_error_flash(service.customer, service.error_message) + render :new, status: :unprocessable_entity + end + end + + def edit + # Add breadcrumb for this specific customer + breadcrumb @customer.decorate.display_name, customer_path(@customer) + end + + def update + service = Customers::UpdateService.new(customer: @customer) + if service.run(params) + redirect_to @customer, notice: "Les informations du client ont été mises à jour." + else + @customer = service.customer + set_error_flash(service.customer, service.error_message) + render :edit, + status: :unprocessable_entity, + alert: service.error_message + end + end + + def lookup + customer = Customer.find_by(email: params[:email]) + + if customer + render json: { + found: true, + firstname: customer.firstname, + lastname: customer.lastname, + phone: customer.phone + } + else + render json: { found: false } + end + end + + def duplicates + @duplicate_groups = Customer.find_duplicates + end + + def merge_duplicates + master_customer_id = params[:master_customer_id] + duplicate_ids = params[:duplicate_ids] || [] + + service = Customers::MergeDuplicatesService.new + if service.run(master_customer_id: master_customer_id, duplicate_ids: duplicate_ids) + redirect_to duplicates_customers_path, + notice: "#{duplicate_ids.length} client(s) fusionné(s) avec succès." + else + redirect_to duplicates_customers_path, + alert: "Erreur lors de la fusion : #{service.error_message}" + end + end + + private + + def get_customer + @customer = Customer.find(params[:id]) + end + + def customer_params + params.require(:customer).permit( + :firstname, :lastname, :phone, :email, :notes, + :company_name, :vat_number, + :street, :number, :box, :postcode, :city, :country + ) + end + + def set_presenters + @menu_presenter = Components::MenuPresenter.new( + active_primary: "customers", + controller_name: controller_name, + action_name: action_name, + view_context: view_context + ) + end + +end diff --git a/app/controllers/pages_controller.rb b/app/controllers/pages_controller.rb index 8c5b744..ae90273 100644 --- a/app/controllers/pages_controller.rb +++ b/app/controllers/pages_controller.rb @@ -18,7 +18,30 @@ def calendar .where.not(booking: { status: ["declined", "canceled"] }) .between_times(@first, @last, field: :date) @grouped_reservations = @reservations.to_a.group_by { |r| r.date } - @activities = PublicActivity::Activity.where("created_at > ?", 14.days.ago).order(created_at: :desc) + # stays + @stay_reservations = StayItemDate.all + .includes(:stay) + .where(direct_book: true) + .where.not(stay: {status: [StayStatus::DECLINED, StayStatus::CANCELED], draft: true } ) + .between_times(@first, @last, field: :booking_date) + + @grouped_stay_reservations = @stay_reservations.to_a.group_by { |sr| sr.booking_date } + + # spaces + space_reservations = @stay_reservations.where(booked_item_type: StayItem::SPACE) + @grouped_spaces = space_reservations.to_a.group_by { |sr| sr.booking_date } + # experiences + exp_reservations = @stay_reservations.where(booked_item_type: StayItem::EXPERIENCE) + @grouped_experiences = exp_reservations.to_a.group_by { |sr| sr.booking_date } + + # rental items TODO? + + # activities + activities_without_stays = PublicActivity::Activity.where("created_at > ?", 14.days.ago).order(created_at: :desc) + .where.not(trackable_type: 'Stay') + stay_activities = Activity.stays_without_drafts.where("created_at > ?", 14.days.ago).order(created_at: :desc) + @activities = (activities_without_stays + stay_activities).sort_by(&:created_at).reverse + end # details for a specific day @@ -36,6 +59,23 @@ def day .includes(:space_booking) .where(date: @date, space_booking: { status: "confirmed" }) ) + + # stays + @stay_reservations = StayItemDateDecorator.decorate_collection(StayItemDate.all + .includes(:stay) + .where(booking_date: @date) + .where(stay: {status: StayStatus::CONFIRMED, draft: false } )) + + # rooms + @stay_room_reservations = @stay_reservations.where(booked_item_type: StayItem::ROOM) + + # spaces + @stay_space_reservations = @stay_reservations.where(booked_item_type: StayItem::SPACE) + + # experiences + exp_reservations = @stay_reservations.where(booked_item_type: StayItem::EXPERIENCE) + + @roles = Role.all @humans = Human.all @human_roles = HumanRole.where(date: @date) @@ -71,6 +111,18 @@ def other_space_bookings render layout: false end + def other_stays + @reservations = StayItemDate.all + .includes(:stay) + .where.not(stay: { status: "declined" }) + .where.not(stay_id: params[:stay_id]) + .between_times(Date.parse(params[:start_date]), Date.parse(params[:end_date]) - 1.day, field: :booking_date) + .order(booking_date: :asc) + # group stays by day + @grouped_reservations = @reservations.to_a.group_by { |r| r.booking_date } + render layout: false + end + private def set_dates diff --git a/app/controllers/payments_controller.rb b/app/controllers/payments_controller.rb index 6bfd266..de0b75a 100644 --- a/app/controllers/payments_controller.rb +++ b/app/controllers/payments_controller.rb @@ -1,5 +1,5 @@ class PaymentsController < BaseController - before_action :get_booking, except: [:index, :show, :destroy] + before_action :get_reservation, except: [:index, :show, :destroy] before_action :get_payment, only: [:show, :edit, :update, :destroy] before_action :ensure_frame_response, only: [:new, :edit] @@ -17,18 +17,15 @@ def show end def new - @payment = @booking.payments.new(amount_cents: nil) - if @booking.from_airbnb? - @payment.payment_method = "airbnb" - end + init_reservation end def create - service = Payments::CreateService.new(booking_id: @booking.id) + service = init_create_service respond_to do |format| if service.run(params) format.turbo_stream { @payment = PaymentDecorator.decorate(service.payment) } - format.html { redirect_to booking_url(service.booking), notice: "Le paiement a été enregistré." } + format.html { redirect_to get_reservation_url(service), notice: "Le paiement a été enregistré." } format.json { render :show, status: :created, location: service.payment } else format.html { @@ -48,7 +45,7 @@ def update respond_to do |format| if service.run(params) format.turbo_stream { @payment = PaymentDecorator.decorate(service.payment) } - format.html { redirect_to booking_url(service.booking), notice: "Le paiement a été mis à jour." } + format.html { redirect_to get_reservation_url(service.reservation), notice: "Le paiement a été mis à jour." } format.json { render :show, status: :ok, location: service.payment } else format.html { @@ -65,7 +62,7 @@ def destroy if service.run respond_to do |format| format.turbo_stream { @payment = PaymentDecorator.decorate(service.payment) } - format.html { redirect_to booking_url(@payment.booking), notice: "Le paiement a été supprimé." } + format.html { redirect_to get_reservation_url(service), notice: "Le paiement a été supprimé." } format.json { head :no_content } end end @@ -73,11 +70,52 @@ def destroy private - def get_booking - breadcrumb "Hébergements", :bookings_path, match: :exact - @booking = Booking.find(params[:booking_id]) + def init_reservation + if params[:booking_id] + @payment = @booking.payments.new(amount_cents: nil) + if @booking.from_airbnb? + @payment.payment_method = "airbnb" + end + elsif params[:stay_id] + @payment = @stay.payments.new(amount_cents: nil) + if @stay.from_airbnb? + @payment.payment_method = "airbnb" + end + end + end + + def init_create_service + if @booking + Payments::CreateService.new(reservation_type: 'Booking', reservation_id: @booking.id) + elsif @stay + Payments::CreateService.new(reservation_type: 'Stay', reservation_id: @stay.id) + end + + end + + def get_reservation + if params[:booking_id] + breadcrumb "Hébergements", :bookings_path, match: :exact + @booking = Booking.find(params[:booking_id]) + elsif params[:stay_id] + breadcrumb "Séjours", :stays_path, match: :exact + @stay = Stay.find(params[:stay_id]) + elsif params[:payment_id] + payment = Payment.find(params[:payment_id]) + @booking = payment.booking unless payment.booking.nil? + @stay = payment.stay unless payment.stay.nil? + end end + def get_reservation_url(service) + if @booking + booking_url(service.reservation) + elsif @stays + stay_url(service.reservation) + end + end + + def get_payment @payment = Payment.find(params[:id]) end diff --git a/app/controllers/public/payments_controller.rb b/app/controllers/public/payments_controller.rb index 1a2e7b7..d9274a8 100644 --- a/app/controllers/public/payments_controller.rb +++ b/app/controllers/public/payments_controller.rb @@ -7,7 +7,7 @@ def pay allow_other_host: true, data: { turbo: false } else - redirect_to public_booking_path(payment.booking.token), + redirect_to public_booking_path(payment.stay.token), alert: "Une erreur est survenue et celle-ci nous empêche de vous rediriger vers le paiement en ligne. Veuillez nous contacter à sejours@les4sources.be." end diff --git a/app/controllers/public/stays_controller.rb b/app/controllers/public/stays_controller.rb new file mode 100644 index 0000000..a0ff25e --- /dev/null +++ b/app/controllers/public/stays_controller.rb @@ -0,0 +1,12 @@ +class Public::StaysController < Public::BaseController + layout "public_sheet" + + def show + @stay = Stay.find_by!(token: params[:token]).decorate + @stay_items = @stay.stay_items.decorate + @ordered_products = @stay.stay_items.where(item_type: StayItem::PRODUCT) + # @reservations_by_date = @stay.reservations.decorate.to_a.group_by { |r| r.date } + rescue ActiveRecord::RecordNotFound + raise ActionController::RoutingError.new('Not Found') + end +end diff --git a/app/controllers/reports_controller.rb b/app/controllers/reports_controller.rb index f3eca49..a5a7493 100644 --- a/app/controllers/reports_controller.rb +++ b/app/controllers/reports_controller.rb @@ -22,7 +22,6 @@ def index def lodging @lodging = LodgingDecorator.new(Lodging.find(params[:id])) @year = params.fetch(:year, Time.now.year).to_i - end private diff --git a/app/controllers/stay_items_controller.rb b/app/controllers/stay_items_controller.rb new file mode 100644 index 0000000..d0bac13 --- /dev/null +++ b/app/controllers/stay_items_controller.rb @@ -0,0 +1,164 @@ +class StayItemsController < BaseController + before_action :get_stay + before_action :get_stay_item, only: [:edit, :update, :destroy] + before_action :ensure_frame_response, only: [:new, :edit] + + layout "modal" + + def new + @stay_item = StayItem.new(item_type: params[:type]) + @stay_item.start_date = @stay.start_date+1 + @stay_item.end_date = @stay.end_date+1 + set_modal_title + end + + def create + service = StayItems::CreateService.new(stay_id: @stay.id) + if service.run(params) + respond_to do |format| + format.turbo_stream { + render turbo_stream: [ + turbo_stream.update( + "stay-items", + partial: 'stay_items/stay_item', + collection: @stay.stay_items.order_by_item_type.decorate, + as: :stay_item + ), + turbo_stream.replace( + "total-amount", + partial: 'stays/total_amount', + locals: { total_amount: @stay.total_reservation_amount } + ), + turbo_stream.replace( + "final-price-container", + partial: 'stays/form/final_price', + locals: { stay: @stay } + ) + ] + } + format.html { + redirect_to edit_stay_path(service.stay), + notice: "L'élément a été ajouté au séjour." + } + format.json { + render :show, + status: :created, + location: service.stay_item + } + end + else + @stay_item = service.stay_item + set_error_flash(service.stay_item, service.error_message) + render :new + end + end + + def edit + @stay_item = StayItem.find_by!(id: params[:id]) + set_modal_title("Modifier") + end + + def update + service = StayItems::UpdateService.new(stay_item_id: @stay_item.id) + if service.run(params) + respond_to do |format| + format.turbo_stream { + #render turbo_stream: turbo_stream.append("stay-items", + # partial: 'stay_items/stay_item', + # locals: { stay_item: service.stay_item.decorate }) } + render turbo_stream: [ + turbo_stream.replace( + "stay_item_#{service.stay_item.id}", + partial: 'stay_items/stay_item', + locals: { stay_item: service.stay_item.decorate } + ), + turbo_stream.replace( + "total-amount", + partial: 'stays/total_amount', + locals: { total_amount: @stay.total_reservation_amount } + )#, + # turbo_stream.replace( + # "stay_final_price", + # partial: 'stays/form/final_price', + # locals: { stay: @stay } + # ) + ] + } + format.html { + redirect_to edit_stay_path(service.stay), + notice: "L'élément a été ajouté au séjour." + } + format.json { + render :show, + status: :udpated, + location: service.stay_item + } + end + else + @stay_item = service.stay_item + set_error_flash(service.stay_item, service.error_message) + render :new + end + end + + def destroy + @stay_item.destroy + respond_to do |format| + format.turbo_stream { + render turbo_stream: + [turbo_stream.remove(@stay_item), + turbo_stream.replace( + "total-amount", + partial: 'stays/total_amount', + locals: { total_amount: @stay.total_reservation_amount } + ) + ] + } + format.html { redirect_to edit_stay_url(@stay_item.stay), notice: "L'élément a été retiré du séjour." } + format.json { head :no_content } + end + end + + private + + def ensure_frame_response + return unless Rails.env.development? + redirect_to root_path unless turbo_frame_request? + end + + def get_stay + @stay = Stay.find(params[:stay_id]) + end + + def get_stay_item + @stay_item = StayItem.find(params[:id]) + end + + def set_modal_title(action="Ajouter") + case @stay_item.item_type + when StayItem::EXPERIENCE + @modal_title = "#{action} un atelier" + when StayItem::LODGING + @modal_title = "#{action} un hébergement" + when StayItem::ROOM + @modal_title = "#{action} une chambre" + when StayItem::BED + @modal_title = "#{action} un lit" + when StayItem::PRODUCT + @modal_title = "#{action} un produit" + when StayItem::RENTAL_ITEM + @modal_title = "#{action} une location" + when StayItem::SPACE + @modal_title = "#{action} un espace" + end + end + + def set_presenters + @menu_presenter = Components::MenuPresenter.new( + active_primary: "stay_items", + controller_name: controller_name, + action_name: action_name, + view_context: view_context + ) + end +end \ No newline at end of file diff --git a/app/controllers/stay_prices_controller.rb b/app/controllers/stay_prices_controller.rb new file mode 100644 index 0000000..40ecb0b --- /dev/null +++ b/app/controllers/stay_prices_controller.rb @@ -0,0 +1,35 @@ +class StayPricesController < BaseController + skip_before_action :authenticate_user! + + def create + service = StayPrices::CalculationService.new + if service.run(params) + render json: { amount: service.amount }, + status: :ok + else + render json: { amount: service.amount }, + status: :unprocessable_entity + end + end + + + def calculate_item_price + service = StayPrices::CalculationService.new + if service.run(params) + render json: { amount: service.amount }, + status: :ok + else + render json: { amount: service.amount }, + status: :unprocessable_entity + end + + end + + + + private + + def set_presenters; end + + +end diff --git a/app/controllers/stays_controller.rb b/app/controllers/stays_controller.rb new file mode 100644 index 0000000..f262316 --- /dev/null +++ b/app/controllers/stays_controller.rb @@ -0,0 +1,110 @@ +class StaysController < BaseController + + def index + @stays = StayDecorator.decorate_collection(Stay.current_and_future) + end + + def past + @stays = StayDecorator + .decorate_collection(Stay.past.paginate(page: params[:page], per_page: 40)) + end + + def new + if !params[:source_booking_id].nil? + #duplication_service = Bookings::DuplicateService.new + #duplication_service.run!(source_booking_id: params[:source_booking_id]) + #@stay = duplication_service.booking + else + @stay = Stay.create( + draft: true, + platform: "direct", + status: "init", + start_date: params.fetch("date", nil), + user_id: current_user.id, + token: Stay.generate_token + ) + @stay.payments.build(amount_cents: nil) + end + @stay.build_customer + end + + # stays are created on init, with stay.draft == true + # def create + # service = Stays::CreateService.new + # if service.run(params) + # redirect_to service.stay, + # notice: "Merci, la réservation a été enregistrée." + # else + # @stay = service.stay + # set_error_flash(service.stay, "<strong>Cette réservation n'a pas pu être enregistrée, merci de vérifier les éléments suivants:</strong><br>#{service.error_message}") + # render :new, status: :unprocessable_entity + # end + # end + + def edit + @stay = Stay.find_by!(id: params[:id]) + @stay.build_customer if @stay.customer.nil? + @stay.payments.build(amount_cents: nil) if @stay.payments.empty? + end + + def update + service = Stays::UpdateService.new(stay_id: params[:id]) + respond_to do |format| + if service.run(params) + format.html { redirect_to service.stay, notice: "Le séjour a été enregistré." } + format.json { render :show, status: :ok, location: service.stay } + else + set_error_flash(service.stay, "<strong>Ce séjour n'a pas pu être enregistré, merci de vérifier les éléments suivants:</strong><br>#{service.error_message}") + format.html { + @stay = service.stay + render :edit, + status: :unprocessable_entity, + alert: service.error_message + } + format.json { render json: service.error_message, status: :unprocessable_entity } + end + end + end + + def save_dates + stay = Stay.find(params[:id]) + stay.update(stay_dates_params) + end + + def show + @stay = Stay.unscoped.find_by!(id: params[:id]).decorate + @reservations_by_date = @stay.stay_item_dates + .where(booked_item_type: StayItem::ROOM) + .decorate.to_a.group_by { |r| r.booking_date } + end + + def destroy + @stay = Stay.find_by!(id: params[:id]) + @stay.soft_delete!(validate: false) + @stay.create_activity(:destroy) + redirect_to stays_url, + status: :see_other, + notice: "Le séjour a été supprimé." + end + + private + + def set_presenters + @menu_presenter = Components::MenuPresenter.new( + active_primary: "stays", + controller_name: controller_name, + action_name: action_name, + view_context: view_context + ) + end + + def stay_dates_params + params + .require(:stay) + .permit( + :start_date, + :end_date + ) + end + +end \ No newline at end of file diff --git a/app/decorators/customer_decorator.rb b/app/decorators/customer_decorator.rb new file mode 100644 index 0000000..7dd199e --- /dev/null +++ b/app/decorators/customer_decorator.rb @@ -0,0 +1,84 @@ +class CustomerDecorator < ApplicationDecorator + delegate_all + + def self.collection_decorator_class + PaginatingDecorator + end + + def display_name + if company_name.present? + company_name + else + full_name + end + end + + def full_name + name = "#{lastname} #{firstname}".strip + name.present? ? name : "(nom non renseigné)" + end + + def formatted_address + return nil if street.blank? && city.blank? + + address_parts = [] + + # Ligne 1: Rue, numéro, boîte + street_line = [] + street_line << street if street.present? + street_line << number if number.present? + street_line << "boîte #{box}" if box.present? + address_parts << street_line.join(", ") if street_line.any? + + # Ligne 2: Code postal et ville + city_line = [] + city_line << postcode if postcode.present? + city_line << city if city.present? + address_parts << city_line.join(" ") if city_line.any? + + # Ligne 3: Pays + address_parts << country if country.present? && country != "Belgique" + + address_parts.join("<br>").html_safe + end + + def contact_person_info + return nil if company_name.blank? + full_name if firstname.present? || lastname.present? + end + + def confirmed_stays_count + object.stays.where(status: 'confirmed').count + end + + def total_revenue + total_payments = object.stays.joins(:payments) + .where(payments: { status: ['paid', 'pending'] }) + .sum('payments.amount_cents') + h.number_to_currency(total_payments / 100.0) + end + + def contact_info + info_parts = [] + info_parts << email if email.present? + info_parts << phone if phone.present? + info_parts.join(' • ') + end + + def latest_stay + object.stays.order(start_date: :desc).first + end + + def stays_status_summary + confirmed = confirmed_stays_count + total = object.stays.count + pending = object.stays.where(status: 'pending').count + + parts = [] + parts << "#{confirmed} confirmé#{'s' if confirmed > 1}" if confirmed > 0 + parts << "#{pending} en attente" if pending > 0 + parts << "#{total - confirmed - pending} autre#{'s' if (total - confirmed - pending) > 1}" if (total - confirmed - pending) > 0 + + parts.join(', ') + end +end \ No newline at end of file diff --git a/app/decorators/lodging_decorator.rb b/app/decorators/lodging_decorator.rb index bdc6477..734593a 100644 --- a/app/decorators/lodging_decorator.rb +++ b/app/decorators/lodging_decorator.rb @@ -1,6 +1,7 @@ class LodgingDecorator < ApplicationDecorator delegate_all + # to be deprecated (booking) def availability_badge(date) if object.available_on?(date) h.content_tag(:span, class: "inline-flex items-center gap-x-1.5 rounded-full bg-green-100 px-2 py-2 text-xs font-medium text-green-700") do @@ -17,6 +18,23 @@ def availability_badge(date) end end + # stay + def available_badge(date) + if object.is_available_on?(date) + h.content_tag(:span, class: "inline-flex items-center gap-x-1.5 rounded-full bg-green-100 px-2 py-2 text-xs font-medium text-green-700") do + h.content_tag(:svg, class: "h-1.5 w-1.5 fill-green-500", viewBox: "0 0 6 6", "aria-hidden": "true") do + h.content_tag(:circle, nil, { cx: "3", cy: "3", r: "3" }) + end + object.name + end + else + h.content_tag(:span, class: "inline-flex items-center gap-x-1.5 rounded-full bg-red-100 px-2 py-2 text-xs font-medium text-red-700") do + h.content_tag(:svg, class: "h-1.5 w-1.5 fill-red-500", viewBox: "0 0 6 6", "aria-hidden": "true") do + h.content_tag(:circle, nil, { cx: "3", cy: "3", r: "3" }) + end + object.name + end + end + end + def average_booking_duration(start_date, end_date) duration = object.average_booking_duration(start_date, end_date) if duration.zero? @@ -53,15 +71,23 @@ def count_people(start_date, end_date) end def monthly_reports_bar(date) - bookings_dates = Reservation - .includes(:booking) - .where(date: date..date.end_of_month, booking: { status: "confirmed", lodging: object }) - .pluck(:date).uniq + # bookings_dates = Reservation + # .includes(:booking) + # .where(date: date..date.end_of_month, booking: { status: "confirmed", lodging: object }) + # .pluck(:date).uniq + stay_dates = StayItemDate + .includes(:stay) + .where( + booking_date: date..date.end_of_month, + booked_item_type: StayItem::LODGING, + booked_item_id: object.id, + stay: { status: "confirmed" }) + .pluck(:booking_date).uniq out = ActiveSupport::SafeBuffer.new date.upto(date.end_of_month).each do |current_date| default_class = current_date.on_weekend? ? "bg-green-500" : "bg-green-300" - out << h.content_tag(:div, nil, class: "w-1 h-2 mr-px #{bookings_dates.include?(current_date) ? "bg-red-500" : default_class}") + out << h.content_tag(:div, nil, class: "w-1 h-2 mr-px #{stay_dates.include?(current_date) ? "bg-red-500" : default_class}") end out.html_safe end diff --git a/app/decorators/payment_decorator.rb b/app/decorators/payment_decorator.rb index 7d5d315..7f4e248 100644 --- a/app/decorators/payment_decorator.rb +++ b/app/decorators/payment_decorator.rb @@ -1,11 +1,14 @@ class PaymentDecorator < ApplicationDecorator delegate_all decorates_association :booking + decorates_association :stay def amount h.number_to_currency(object.amount) end + + def booking_date_range booking.date_range rescue @@ -27,6 +30,18 @@ def booking_payment_status booking.payment_status end + def stay_date_range + stay.date_range + end + + def stay_name + stay.group_or_name + end + + def stay_payment_status + stay.payment_status + end + def created_at(format: :default) h.l(object.created_at.to_date, format: format) end @@ -76,6 +91,8 @@ def status case object.status when "pending" h.content_tag(:span, "En attente", class: "#{shared_classes} bg-red-200 text-red-800") + when "partially_paid" + h.content_tag(:span, "Acompte", class: "#{shared_classes} bg-yellow-200 text-yellow-800") when "paid" h.content_tag(:span, "Payé", class: "#{shared_classes} bg-green-200 text-green-800") end diff --git a/app/decorators/payment_request_decorator.rb b/app/decorators/payment_request_decorator.rb new file mode 100644 index 0000000..efa5d17 --- /dev/null +++ b/app/decorators/payment_request_decorator.rb @@ -0,0 +1,82 @@ +class PaymentRequestDecorator < ApplicationDecorator + delegate_all + decorates_association :stay + + def total_requested_amount + h.number_to_currency(object.amount_cents/100) + end + + def remaining_amount + h.number_to_currency(object.remaining_amount/100) + end + + + + def created_at(format: :default) + h.l(object.created_at.to_date, format: format) + end + + def line + line_content = case object.payment_method + when "airbnb" + "Payé #{amount} via Airbnb" + when "bank_transfer" + "Payé #{amount} par virement bancaire" + when "cash" + "Payé #{amount} en liquide" + when "stripe" + "Payé #{amount} en ligne" + end + h.content_tag(:p, line_content, class: "text-sm text-gray-900") + end + + def payment_method + case object.payment_method + when "airbnb" + "Airbnb" + when "bank_transfer" + "Virement" + when "cash" + "Liquide" + when "stripe" + "En ligne" + end + end + + def payment_method_emoji + case object.payment_method + when "cash" + h.content_tag(:div, "💶", class: "ml-1") + when "bank_transfer" + h.content_tag(:div, "🏦", class: "ml-1") + when "stripe" + h.content_tag(:div, "💳", class: "ml-1") + when "airbnb" + h.render("shared/airbnb_icon") + end + end + + def status + shared_classes = "payment-#{object.id}-status text-xs font-medium mr-2 px-2.5 py-0.5 rounded" + case object.status + when "pending" + h.content_tag(:span, "En attente", class: "#{shared_classes} bg-red-200 text-red-800") + when "partially_paid" + h.content_tag(:span, "Acompte", class: "#{shared_classes} bg-yellow-200 text-yellow-800") + when "paid" + h.content_tag(:span, "Payé", class: "#{shared_classes} bg-green-200 text-green-800") + end + end + + def tr_class + if object.status == "paid" + "text-gray-900" + else + "text-gray-500" + end + end + + def updated_at(format: :default) + h.l(object.updated_at.to_date, format: format) + end +end diff --git a/app/decorators/stay_decorator.rb b/app/decorators/stay_decorator.rb new file mode 100644 index 0000000..612f4b5 --- /dev/null +++ b/app/decorators/stay_decorator.rb @@ -0,0 +1,313 @@ +class StayDecorator < ApplicationDecorator + delegate_all + + def self.collection_decorator_class + PaginatingDecorator + end + + def customer_name + if object.customer.company_name.presence + object.customer.company_name + elsif object.group_name.presence + object.group_name + else + object.customer.full_name + end + end + + def group_or_name + classes = object.deleted? ? "line-through" : nil + if object.group_name.presence + h.content_tag(:span, group_name, class: classes) + else + h.content_tag(:span, name, class: classes) + end + end + + def date_range + if object.start_date.year == object.end_date.year + if object.start_date == object.end_date + # Même jour + "#{l(object.start_date, format: :short_with_year)}" + elsif object.start_date.month == object.end_date.month && object.start_date.year == Date.today.year + # Même mois et année en cours + "du #{object.start_date.day} au #{l(object.end_date, format: :short)} #{object.start_date.year}" + elsif object.start_date.month == object.end_date.month + # Même mois, mais année différente de l'année en cours + "du #{object.start_date.day} au #{object.end_date.day} #{l(object.start_date, format: :month_year)}" + else + # Mêmes années, mois différents + "du #{l(object.start_date, format: :short)} au #{object.end_date.day} #{l(object.end_date, format: :month_year)}" + end + else + # Années différentes + "du #{object.start_date.day} #{l(object.start_date, format: :month_year)} au #{object.end_date.day} #{l(object.end_date, format: :month_year)}" + end + end + + + def lodging_badge(font_size: "xs") + if !object.lodgings.empty? + shared_classes = "text-#{font_size} font-semibold text-center py-0.5 px-1 rounded" + case object.lodgings.first.id + when 1 + if object.confirmed? + h.content_tag(:span, "Chevêche", class: "#{shared_classes} bg-emerald-100 text-emerald-800") + else + h.content_tag(:span, "Chevêche", class: "#{shared_classes} border border-emerald-200 text-emerald-800") + end + when 2 + if object.confirmed? + h.content_tag(:span, "Hulotte", class: "#{shared_classes} bg-emerald-200 text-emerald-800") + else + h.content_tag(:span, "Hulotte", class: "#{shared_classes} border border-emerald-200 text-emerald-800") + end + when 3 + if object.confirmed? + h.content_tag(:span, "Grand-Duc", class: "#{shared_classes} bg-emerald-300 text-emerald-800") + else + h.content_tag(:span, "Grand-Duc", class: "#{shared_classes} border border-emerald-200 text-emerald-800") + end + end + end + end + + + def rooms_badges(font_size: "xs") + rooms = Room.where(id: object.rooms.map(&:id).uniq) + shared_classes = "text-#{font_size} font-semibold text-center py-0.5 px-1 rounded" + html = "" + rooms.each do |room| + case room.level + when 0 + if object.confirmed? + specific_classes = "bg-indigo-100 text-indigo-800" + else + specific_classes = "border border-indigo-100 text-indigo-800" + end + when 1 + if object.confirmed? + specific_classes = "bg-purple-100 text-purple-800" + else + specific_classes = "border border-purple-100 text-purple-800" + end + when 2 + if object.confirmed? + specific_classes = "bg-pink-100 text-pink-800" + else + specific_classes = "border border-pink-100 text-pink-800" + end + end + html << h.content_tag( + :span, + room.code, + class: "#{shared_classes} #{specific_classes}", + "data-tooltip-target": "tooltip-room-#{room.id}" + ) + end + h.raw(html) + end + + + def tr_class + if 1==0#object.deleted? + "bg-stone-50 opacity-50" + elsif object.confirmed? + "bg-white" + elsif object.declined? + "bg-red-50 opacity-75" + else + "bg-yellow-50 opacity-75" + end + end + + def tr_border_class + classes = ["border-l-8"] + if object.customer.nil? + classes << ["border-teal-500"] + else + classes << ["border-orange-500"] + end + classes.join(" ") + end + + def status_emoji + case object.status + when "canceled" + h.content_tag(:span, "❌", data: { "tooltip-target": "tooltip-status-canceled" }) + when "confirmed" + h.content_tag(:span, "✅", data: { "tooltip-target": "tooltip-status-confirmed" }) + when "pending" + h.content_tag(:span, "⏳", data: { "tooltip-target": "tooltip-status-pending" }) + when "declined" + h.content_tag(:span, "🙅‍♀️", data: { "tooltip-target": "tooltip-status-declined" }) + else + object.status + end + end + + def people_emojis + emojis = [] + if object.adults > 0 + emojis << "#{object.adults} 🧑" + end + if (object.children || 0) > 0 + emojis << "#{object.children} 🧒" + end + if (object.babies || 0) > 0 + emojis << "#{object.babies} 👶" + end + emojis.join(" ") + end + + def calendar_class + classes = ["shadow", "border-l-4"] + if !object.confirmed? + classes << ["opacity-50"] + end + if object.lodgings.empty? + classes << ["border-purple-500", "bg-purple-50"] + else + classes << ["border-emerald-500", "bg-emerald-50"] + end + classes.join(" ") + end + + def space_calendar_class + classes = ["shadow", "border-l-4", "border-l-orange-500", "bg-orange-50"] + if !object.confirmed? + classes << ["opacity-50"] + end + classes.join(" ") + end + + def experience_calendar_class + classes = ["shadow", "border-l-4", "border-l-green-500", "bg-green-50"] + if !object.confirmed? + classes << ["opacity-50"] + end + classes.join(" ") + end + + def dates_counter(current_date) + if object.end_date == object.start_date + 1.day + else + total_days = (object.end_date - object.start_date).to_i + if object.start_date == current_date + "(1/#{total_days})" + else + day = (current_date - object.start_date + 1).to_i + "(#{day}/#{total_days})" + end + end + end + + def payment_status_color + case object.payment_status + when "pending" + "red-200" + when "partially_paid" + "yellow-200" + when "paid" + "green-200" + else + "gray-200" + end + end + + def payments_total + h.content_tag :span, + h.number_to_currency(payments.sum(:amount_cents) / 100.0), + id: "stay-#{object.id}-payments-sum" + end + + + def total_remaining_amount + h.number_to_currency(object.total_remaining_amount) + end + + def total_amount + h.number_to_currency(object.final_price) + end + + + def status + shared_classes = "text-xs font-medium mr-2 px-2.5 py-0.5 rounded" + if object.deleted? + h.content_tag(:span, "Supprimée", class: "#{shared_classes} bg-red-100 text-red-800 dark:bg-red-200 dark:text-red-900") + else + case object.status + when "canceled" + h.content_tag(:span, "Annulée", class: "#{shared_classes} bg-red-100 text-red-800 dark:bg-red-200 dark:text-red-900") + when "confirmed" + h.content_tag(:span, "Confirmée", class: "#{shared_classes} bg-green-100 text-green-800 dark:bg-green-200 dark:text-green-900") + when "pending" + h.content_tag(:span, "En attente", class: "#{shared_classes} bg-yellow-100 text-yellow-800 dark:bg-yellow-200 dark:text-yellow-900") + else + object.status + end + end + end + + def payment_status + shared_classes = "stay-#{object.id}-payment-status text-xs font-medium mr-2 px-2.5 py-0.5 rounded" + case object.payment_status + when "pending" + h.content_tag(:span, "Non payée", class: "#{shared_classes} bg-red-200 text-red-800") + when "partially_paid" + h.content_tag(:span, "Payée partiellement", class: "#{shared_classes} bg-yellow-200 text-yellow-800") + when "paid" + h.content_tag(:span, "Payée", class: "#{shared_classes} bg-green-200 text-green-800") + else + if object.payment_status.presence + h.content_tag(:span, object.payment_status, class: "#{shared_classes} bg-gray-100 text-gray-800") + end + end + end + + def invoice_status + case object.invoice_status + when "requested" + "À fournir" + when "sent" + "Envoyée ✔" + else + "Non requise" + end + end + + # Affiche les tags hébergement (lodging, rooms, beds) selon les règles métier + def accommodation_tags + html = "" + # Lodging tags + object.lodgings.uniq.each do |lodging| + case lodging.name.downcase + when /chevêche/ + html << h.content_tag(:span, "Chevêche", class: "inline-block bg-green-100 text-green-800 font-medium text-xs px-2.5 py-0.5 rounded mr-2") + when /hulotte/ + html << h.content_tag(:span, "Hulotte", class: "inline-block bg-yellow-100 text-yellow-800 font-medium text-xs px-2.5 py-0.5 rounded mr-2") + when /grand-duc/ + html << h.content_tag(:span, "Grand-Duc", class: "inline-block bg-purple-100 text-purple-800 font-medium text-xs px-2.5 py-0.5 rounded mr-2") + end + end + # Room tags (trigramme) + object.rooms.uniq.each do |room| + trigram = room.name.split.map { |w| w[0,1].upcase }.join[0,3] + html << h.content_tag(:span, trigram, class: "inline-block bg-blue-100 text-blue-800 font-medium text-xs px-2.5 py-0.5 rounded mr-2") + end + # Bed tag + if object.beds.uniq.any? + count = object.beds.count + html << h.content_tag(:span, "#{count} lit#{'s' if count > 1}", class: "inline-block bg-pink-100 text-pink-800 font-medium text-xs px-2.5 py-0.5 rounded mr-2") + end + h.raw(html) + end + + def spaces_tags + html = "" + object.spaces.uniq.each do |space| + html << h.content_tag(:span, space.name, class: "inline-block bg-blue-100 text-blue-800 font-medium text-xs px-2.5 py-0.5 rounded mr-2") + end + h.raw(html) + end +end diff --git a/app/decorators/stay_item_date_decorator.rb b/app/decorators/stay_item_date_decorator.rb new file mode 100644 index 0000000..2169bc3 --- /dev/null +++ b/app/decorators/stay_item_date_decorator.rb @@ -0,0 +1,9 @@ +class StayItemDateDecorator < ApplicationDecorator + delegate_all + + decorates_association :stay + + def booking_date + l(object.booking_date, format: :short) + end +end diff --git a/app/decorators/stay_item_decorator.rb b/app/decorators/stay_item_decorator.rb new file mode 100644 index 0000000..6fb85f0 --- /dev/null +++ b/app/decorators/stay_item_decorator.rb @@ -0,0 +1,87 @@ +class StayItemDecorator < ApplicationDecorator + delegate_all + + def item_name + object.item&.name + end + + def item_type_label + case object.item_type + when StayItem::EXPERIENCE + "Atelier" + when StayItem::LODGING + "Hébergement" + when StayItem::PRODUCT + "Produit" + when StayItem::RENTAL_ITEM + "Location" + when StayItem::SPACE + "Espace" + when StayItem::ROOM + "Chambre" + when StayItem::BED + "Lit" + end + end + + def start_date(format: :long) + l(object.start_date, format: format) + end + + def end_date(format: :long) + l(object.end_date, format: format) + end + + def item_info + case object.item_type + when StayItem::LODGING, StayItem::ROOM, StayItem::BED + date_range + when StayItem::EXPERIENCE + %Q(#{l(object.start_date, format: :short) } - #{object.adults_count||0} adulte(s), #{object.children_count||0} enfant(s) - #{object.duration} ) + when StayItem::SPACE + %Q(#{l(object.start_date, format: :short) } - #{duration} ) + when StayItem::PRODUCT + %Q(quantité commandée: #{object.quantity}) + when StayItem::RENTAL_ITEM + %Q(#{object.quantity} x #{(object.end_date-object.start_date).to_i} jour(s)) + end + end + + + # TODO: shared method with stay_decorator --> DRY it + def date_range + if object.start_date.year == object.end_date.year + if object.start_date.month == object.end_date.month && object.start_date.year == Date.today.year + # Même mois et année en cours + "du #{object.start_date.day} au #{l(object.end_date, format: :short)}" + elsif object.start_date.month == object.end_date.month + # Même mois, mais année différente de l'année en cours + "du #{object.start_date.day} au #{object.end_date.day} #{l(object.start_date, format: :month_year)}" + else + # Mêmes années, mois différents + "du #{l(object.start_date, format: :short)} au #{object.end_date.day} #{l(object.end_date, format: :month_year)}" + end + else + # Années différentes + "du #{object.start_date.day} #{l(object.start_date, format: :month_year)} au #{object.end_date.day} #{l(object.end_date, format: :month_year)}" + end + end + + + def duration + case object.duration + when "2h" + "2 heures" + when "evening" + "soirée" + when "day" + "journée" + when "see_notes" + "voir notes" + when "fullday" + "journée + soirée" + end + end + + +end diff --git a/app/frontend/controllers/stay_controller.js b/app/frontend/controllers/stay_controller.js new file mode 100644 index 0000000..6fe9572 --- /dev/null +++ b/app/frontend/controllers/stay_controller.js @@ -0,0 +1,137 @@ +import { Controller } from "@hotwired/stimulus" +import { FetchRequest } from '@rails/request.js' +import moment from "moment" + +export default class extends Controller { + static values = { + id: Number + } + + static targets = [ + 'stayItems', + 'customerEmail', + 'customerFirstname', + 'customerLastname', + 'customerPhone', + 'startDateInput', + 'endDateInput', + 'staysForDateRange', + 'compositionSection', + 'datesRequiredMessage' + ] + + connect() { + console.log('connect stays', this.idValue) + this.checkDatesCompletion() + } + + initialize() { + console.log('initialize stays') + } + + drawForm(e) { + this.showSimilarStays() + this.checkDatesCompletion() + } + + async lookupCustomer() { + console.log('lookupCustomer') + const email = this.customerEmailTarget.value + + if (email) { + fetch("/customers/lookup?email="+email) + .then(response => response.json()) + .then(data => { + if (data.found) { + console.log("data : ", data) + this.customerFirstnameTarget.value = data.firstname + this.customerLastnameTarget.value = data.lastname + this.customerPhoneTarget.value = data.phone + } else { + console.log("No customer found with that email") + } + }) + } + } + + getStartDate() { + return moment(this.startDateInputTarget.value) + } + + getEndDate() { + return moment(this.endDateInputTarget.value) + } + + setEndDate() { + console.log('setEndDate') + const dayAfterStartDate = this.getStartDate().add(1, 'day') + this.endDateInputTarget.setAttribute('min', dayAfterStartDate.format('YYYY-MM-DD')) + if (this.endDateInputTarget.value == "") { + this.endDateInputTarget.value = dayAfterStartDate.format('YYYY-MM-DD') + } + if (this.getEndDate() <= this.getStartDate()) { + this.endDateInputTarget.value = dayAfterStartDate.format('YYYY-MM-DD') + } + this.checkDatesCompletion() + } + + async saveDates() { + console.log('saveDates') + const request = new FetchRequest( + 'post', + "/stays/"+this.idValue+"/save_dates", + { + body: JSON.stringify({ + stay: { + start_date: this.getStartDate(), + end_date: this.getEndDate(), + } + }) + }) + const response = await request.perform() + if (response.ok) { + //console.log('end date saved') + } + this.checkDatesCompletion() + } + + async showSimilarStays() { + if (this.getStartDate().isValid() && this.getEndDate().isValid()) { + console.log('get other stays...' + this.idValue) + fetch("/pages/other_stays?stay_id=" + this.idValue + "&start_date=" + this.startDateInputTarget.value + "&end_date=" + this.endDateInputTarget.value) + .then(response => response.text()) + .then(html => Turbo.renderStreamMessage(html)); + } else { + console.log('clear bookings list') + this.staysForDateRangeTarget.innerHTML = '' + } + } + + checkDatesCompletion() { + const hasValidDates = this.getStartDate().isValid() && this.getEndDate().isValid() + + if (hasValidDates) { + this.enableComposition() + } else { + this.disableComposition() + } + } + + enableComposition() { + if (this.hasCompositionSectionTarget) { + this.compositionSectionTarget.classList.remove('composition-disabled') + } + if (this.hasDatesRequiredMessageTarget) { + this.datesRequiredMessageTarget.classList.add('hidden') + } + } + + disableComposition() { + if (this.hasCompositionSectionTarget) { + this.compositionSectionTarget.classList.add('composition-disabled') + } + if (this.hasDatesRequiredMessageTarget) { + this.datesRequiredMessageTarget.classList.remove('hidden') + } + } +} \ No newline at end of file diff --git a/app/frontend/controllers/stay_items_controller.js b/app/frontend/controllers/stay_items_controller.js new file mode 100644 index 0000000..19f86a4 --- /dev/null +++ b/app/frontend/controllers/stay_items_controller.js @@ -0,0 +1,192 @@ +import { Controller } from "@hotwired/stimulus" +import { FetchRequest } from '@rails/request.js' + +export default class extends Controller { + static targets = [ + 'itemType', + 'productSelection', + 'productQuantity', + 'productPrice', + 'experienceSelection', + 'experienceStartDate', + 'experienceAdultCount', + 'experienceChildrenCount', + 'experienceDuration', + 'experiencePrice', + 'rentalItemSelection', + 'rentalItemQuantity', + 'rentalItemStartDate', + 'rentalItemEndDate', + 'rentalItemPrice', + 'spaceSelection', + 'spaceStartDate', + 'spaceDuration', + 'spacePrice', + 'lodgingSelection', + 'lodgingStartDate', + 'lodgingEndDate', + 'lodgingPrice', + 'roomSelection', + 'roomStartDate', + 'roomEndDate', + 'roomPrice', + 'bedSelection', + 'bedStartDate', + 'bedEndDate', + 'bedPrice' + + ] + + connect() { + console.log('connect stay items') + } + + initialize() { + console.log('initialize stay items') + } + + + clickProductPrice(event){ + event.preventDefault() + this.calculateProductPrice() + } + + clickExperiencePrice(event){ + event.preventDefault() + this.calculateExperiencePrice() + } + + + clickRentalItemPrice(event){ + event.preventDefault() + this.calculateRentalItemPrice() + } + + clickSpacePrice(event){ + event.preventDefault() + this.calculateSpacePrice() + } + + clickLodgingPrice(event){ + event.preventDefault() + this.calculatePriceFor(this.lodgingSelectionTargets, this.lodgingPriceTarget, this.lodgingStartDateTarget, this.lodgingEndDateTarget) + } + + clickRoomPrice(event){ + event.preventDefault() + this.calculatePriceFor(this.roomSelectionTargets, this.roomPriceTarget, this.roomStartDateTarget, this.roomEndDateTarget) + } + + clickBedPrice(event){ + event.preventDefault() + this.calculatePriceFor(this.bedSelectionTargets, this.bedPriceTarget, this.bedStartDateTarget, this.bedEndDateTarget) + } + + // calculate the price of the given product + async calculateProductPrice(){ + + let selectedProductId = null; + this.productSelectionTargets.forEach((radio) => { + if (radio.checked) { + selectedProductId = radio.value; + } + }); + + fetch("/stay_prices/calculate_item_price?item_type="+this.itemTypeTarget.value +"&item_id=" +selectedProductId + "&quantity="+this.productQuantityTarget.value) + .then(response => response.json()) + .then(data => this.productPriceTarget.value = data.amount); + + } + + // calculate the price of the given experience + async calculateExperiencePrice(targetInput){ + + let selectedExperienceId = null; + this.experienceSelectionTargets.forEach((radio) => { + if (radio.checked) { + selectedExperienceId = radio.value; + } + }); + + let paramsStr = "?item_type="+this.itemTypeTarget.value + + "&item_id=" +selectedExperienceId + + "&start_date="+this.experienceStartDateTarget.value + + "&adult_count="+this.experienceAdultCountTarget.value + + "&children_count="+this.experienceChildrenCountTarget.value + + "&duration="+this.experienceDurationTarget.value + + fetch("/stay_prices/calculate_item_price"+paramsStr) + .then(response => response.json()) + .then(data => this.experiencePriceTarget.value = data.amount); + + } + + + // calculate the price of the given rental item + async calculateRentalItemPrice(){ + + let selectedItemId = null; + this.rentalItemSelectionTargets.forEach((radio) => { + if (radio.checked) { + selectedItemId = radio.value; + } + }); + + let paramsStr = "?item_type="+this.itemTypeTarget.value + + "&item_id=" +selectedItemId + + "&start_date="+this.rentalItemStartDateTarget.value + + "&end_date="+this.rentalItemEndDateTarget.value + + "&quantity="+this.rentalItemQuantityTarget.value + + fetch("/stay_prices/calculate_item_price"+paramsStr) + .then(response => response.json()) + .then(data => this.rentalItemPriceTarget.value = data.amount); + + } + + // calculate the price of the given space + async calculateSpacePrice(){ + let selectedItemId = null; + this.spaceSelectionTargets.forEach((radio) => { + if (radio.checked) { + selectedItemId = radio.value; + } + }); + + let selectedDuration = null; + this.spaceDurationTargets.forEach((radio) => { + if (radio.checked) { + selectedDuration = radio.value; + } + }); + + let paramsStr = "?item_type="+this.itemTypeTarget.value + + "&item_id=" +selectedItemId + + "&duration="+selectedDuration + + fetch("/stay_prices/calculate_item_price"+paramsStr) + .then(response => response.json()) + .then(data => this.spacePriceTarget.value = data.amount); + + } + + // calculate the price of the given lodging + async calculatePriceFor(selectionTargets, priceTarget, startDateTarget, endDateTarget){ + let selectedItemId = null; + selectionTargets.forEach((radio) => { + if (radio.checked) { + selectedItemId = radio.value; + } + }); + + let paramsStr = "?item_type="+this.itemTypeTarget.value + + "&item_id=" +selectedItemId + + "&start_date="+startDateTarget.value + + "&end_date="+endDateTarget.value + fetch("/stay_prices/calculate_item_price"+paramsStr) + .then(response => response.json()) + .then(data => priceTarget.value = data.amount); + + } + +} \ No newline at end of file diff --git a/app/frontend/entrypoints/application.js b/app/frontend/entrypoints/application.js index e81366a..2f4ef2b 100644 --- a/app/frontend/entrypoints/application.js +++ b/app/frontend/entrypoints/application.js @@ -39,3 +39,7 @@ import '@rails/actiontext'; import '../utils/setupStimulus'; import '../stylesheets/application.css'; + +import "select2/dist/css/select2.min.css"; // Import du CSS de Select2 + + diff --git a/app/frontend/images/les-4-sources-cover.jpg b/app/frontend/images/les-4-sources-cover.jpg new file mode 100644 index 0000000..092e5bb Binary files /dev/null and b/app/frontend/images/les-4-sources-cover.jpg differ diff --git a/app/frontend/images/les-4-sources-logo-seul.png b/app/frontend/images/les-4-sources-logo-seul.png new file mode 100644 index 0000000..87338d0 Binary files /dev/null and b/app/frontend/images/les-4-sources-logo-seul.png differ diff --git a/app/frontend/images/les-4-sources-logo-white.png b/app/frontend/images/les-4-sources-logo-white.png new file mode 100644 index 0000000..4a35de7 Binary files /dev/null and b/app/frontend/images/les-4-sources-logo-white.png differ diff --git a/app/frontend/images/orchard.jpg b/app/frontend/images/orchard.jpg new file mode 100644 index 0000000..a028090 Binary files /dev/null and b/app/frontend/images/orchard.jpg differ diff --git a/app/frontend/stylesheets/application.css b/app/frontend/stylesheets/application.css index 620aa06..0ac31a2 100644 --- a/app/frontend/stylesheets/application.css +++ b/app/frontend/stylesheets/application.css @@ -72,6 +72,18 @@ @apply p-4 mb-4 text-sm text-green-700 bg-green-100 rounded-lg dark:bg-green-200 dark:text-green-800; } + /* Styles pour la section composition désactivée */ + .composition-disabled { + @apply opacity-50 relative; + pointer-events: none; + } + + .composition-disabled::before { + content: ''; + @apply absolute top-0 left-0 right-0 bottom-0 bg-white bg-opacity-80 rounded-md; + z-index: 10; + } + #main-menu { li { a { diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index a7eceb1..0d2f578 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -25,6 +25,11 @@ def space_badge(space) content_tag(:span, space.code, class: "#{shared_classes} bg-orange-100 text-orange-800") end + def experience_badge(exp) + shared_classes = "text-xs font-semibold text-center py-0.5 px-1 rounded" + content_tag(:span, exp.name, class: "#{shared_classes} bg-green-100 text-green-800") + end + def will_paginate(coll_or_options = nil, options = {}) if coll_or_options.is_a? Hash options = coll_or_options @@ -33,4 +38,63 @@ def will_paginate(coll_or_options = nil, options = {}) options = options.merge renderer: Pagination::TailwindUIPaginationRenderer unless options[:renderer] super(*[coll_or_options, options].compact) end + + def format_date(date) + date.present? ? date.strftime("%d/%m/%Y") : '' + end + + def item_badge(item_type, label) + shared_classes = "text-xs font-semibold text-center px-2 py-0.5 rounded" + content_tag(:span, label, class: "#{shared_classes} #{item_color(item_type)}") + end + + def lodging_color + "bg-yellow-200" + end + + def room_color + "bg-orange-400" + end + + def bed_color + "bg-purple-400" + end + + def experience_color + "bg-green-400" + end + + def space_color + "bg-blue-400" + end + + def rental_item_color + "bg-pink-200" + end + + def product_color + "bg-red-400" + end + + private + + def item_color(item_type) + case item_type + when StayItem::EXPERIENCE + experience_color + when StayItem::LODGING + lodging_color + when StayItem::PRODUCT + product_color + when StayItem::RENTAL_ITEM + rental_item_color + when StayItem::SPACE + space_color + when StayItem::ROOM + room_color + when StayItem::BED + bed_color + end + end + end diff --git a/app/helpers/customers_helper.rb b/app/helpers/customers_helper.rb new file mode 100644 index 0000000..c308308 --- /dev/null +++ b/app/helpers/customers_helper.rb @@ -0,0 +1,26 @@ +module CustomersHelper + def country_options + [ + ['Belgique', 'Belgique'], + ['France', 'France'], + ['Allemagne', 'Allemagne'], + ['Pays-Bas', 'Pays-Bas'], + ['Luxembourg', 'Luxembourg'], + ['Royaume-Uni', 'Royaume-Uni'], + ['Suisse', 'Suisse'], + ['Italie', 'Italie'], + ['Espagne', 'Espagne'], + ['Portugal', 'Portugal'], + ['Autriche', 'Autriche'], + ['Danemark', 'Danemark'], + ['Norvège', 'Norvège'], + ['Suède', 'Suède'], + ['Finlande', 'Finlande'], + ['Pologne', 'Pologne'], + ['République tchèque', 'République tchèque'], + ['Canada', 'Canada'], + ['États-Unis', 'États-Unis'], + ['Autre', 'Autre'] + ] + end +end \ No newline at end of file diff --git a/app/models/activity.rb b/app/models/activity.rb new file mode 100644 index 0000000..cb70734 --- /dev/null +++ b/app/models/activity.rb @@ -0,0 +1,21 @@ +# == Schema Information +# +# Table name: activities +# +# id :integer not null, primary key +# trackable_type :string +# trackable_id :integer +# owner_type :string +# owner_id :integer +# key :string +# parameters :text +# recipient_type :string +# recipient_id :integer +# created_at :datetime not null +# updated_at :datetime not null +# +class Activity < PublicActivity::Activity + scope :stays_without_drafts, -> { + where(trackable_type: 'Stay', trackable_id: Stay.draft_excluded.select(:id)) + } +end diff --git a/app/models/bed.rb b/app/models/bed.rb new file mode 100644 index 0000000..e28b50c --- /dev/null +++ b/app/models/bed.rb @@ -0,0 +1,53 @@ +# == Schema Information +# +# Table name: beds +# +# id :bigint not null, primary key +# name :string +# description :text +# price_cents :integer +# room_id :bigint +# created_at :datetime not null +# updated_at :datetime not null +# +class Bed < ApplicationRecord + + belongs_to :room + # v2 - stays + has_many :stay_items, as: :item + has_many :stays, through: :stay_items + has_many :stay_item_dates, as: :booked_item + + validates :name, presence: true + + + default_scope { order(name: :asc) } + + +# price constant + # night_count => price + # currently same price for each room, add the bed_id as an up hash key should beds had different prices + PRICES = { + 1 => 3500, + 2 => 7000, + 3 => 10500, + 4 => 14000, + 5 => 17500, + 6 => 21000 + }.freeze + + def price(nights_count) + PRICES[nights_count] if nights_count + end + + + def name_with_room + "#{self.name} (#{self.room.name})" + end + + def form_label + "#{name} (#{description}) - chambre #{self.room.name} " + end + + +end diff --git a/app/models/booking.rb b/app/models/booking.rb index ef62431..a976a9e 100644 --- a/app/models/booking.rb +++ b/app/models/booking.rb @@ -157,7 +157,7 @@ def has_options? end def name - "#{firstname} #{lastname}" + "#{self.customer.firstname} #{self.customer.lastname}" end def nights_count diff --git a/app/models/customer.rb b/app/models/customer.rb new file mode 100644 index 0000000..5c0a9eb --- /dev/null +++ b/app/models/customer.rb @@ -0,0 +1,52 @@ +# == Schema Information +# +# Table name: customers +# +# id :bigint not null, primary key +# firstname :string +# lastname :string +# phone :string +# email :string +# notes :text +# created_at :datetime not null +# updated_at :datetime not null +# company_name :string +# vat_number :string +# street :string +# number :string +# box :string +# postcode :string +# city :string +# country :string default("Belgique") +# +class Customer < ApplicationRecord + + has_many :stays + + def full_name + name = "#{firstname} #{lastname}".strip + name.present? ? name : "(nom non renseigné)" + end + + def self.find_duplicates + # Grouper les clients par nom/prénom (insensible à la casse) + customers_by_name = Customer.all + .select { |c| c.firstname.present? && c.lastname.present? } + .group_by { |c| "#{c.firstname.strip.downcase} #{c.lastname.strip.downcase}" } + + # Ne garder que les groupes avec plus d'un client + duplicate_groups = customers_by_name + .select { |name, customers| customers.length > 1 } + .map { |name, customers| customers.sort_by(&:created_at) } + + duplicate_groups + end + + def display_info + info = full_name + info += " (#{email})" if email.present? + info += " - #{phone}" if phone.present? + info += " - #{stays.count} séjour(s)" + info + end +end diff --git a/app/models/experience.rb b/app/models/experience.rb index f70b28b..74ef5f1 100644 --- a/app/models/experience.rb +++ b/app/models/experience.rb @@ -20,6 +20,10 @@ class Experience < ApplicationRecord belongs_to :human, optional: true + # v2 - stays + has_many :stay_items, as: :item + has_many :stays, through: :stay_items + has_paper_trail has_soft_deletion default_scope: true diff --git a/app/models/human.rb b/app/models/human.rb index dc86f72..25d785b 100644 --- a/app/models/human.rb +++ b/app/models/human.rb @@ -11,6 +11,7 @@ # deleted_at :datetime # created_at :datetime not null # updated_at :datetime not null +# status :string default("active") # class Human < ApplicationRecord has_many :projects diff --git a/app/models/lodging.rb b/app/models/lodging.rb index 9c8257e..3693665 100644 --- a/app/models/lodging.rb +++ b/app/models/lodging.rb @@ -13,6 +13,7 @@ # weekend_discount_cents :integer default(0), not null # deleted_at :datetime # show_on_reports :boolean default(TRUE) +# available_for_bookings :boolean # class Lodging < ApplicationRecord has_many :lodging_rooms @@ -20,10 +21,51 @@ class Lodging < ApplicationRecord has_many :bookings has_many :unavailabilities + # v2 - stays + has_many :stay_items, as: :item + has_many :stays, through: :stay_items + has_many :stay_item_dates, as: :booked_item + monetize :price_night_cents has_soft_deletion default_scope: true + + default_scope { order(name: :asc) } + + # price constant + # lodging_id => {night_count => price } + PRICES = { + 1 => { + 1 => 23000, + 2 => 44000, + 3 => 62000, + 4 => 78000, + 5 => 92000, + 6 => 103500 + }, + 2 => { + 1 => 48000, + 2 => 91000, + 3 => 130000, + 4 => 163000, + 5 => 192000, + 6 => 216000 + }, + 3 => { + 1 => 71000, + 2 => 135000, + 3 => 192000, + 4 => 241000, + 5 => 284000, + 6 => 319500 + } + }.freeze + + def price(nights_count) + PRICES[id][nights_count] if PRICES[id] + end + def available_between?(from_date, to_date) # none of the lodging rooms has a confirmed reservation Reservation.includes(:booking) @@ -34,6 +76,7 @@ def available_between?(from_date, to_date) ).none? && unavailabilities.where(date: from_date..to_date).none? end + # to be deprecated def available_on?(date) # none of the lodging rooms has a confirmed reservation Reservation.includes(:booking) @@ -44,6 +87,17 @@ def available_on?(date) ).none? && unavailabilities.where(date: date).none? end + def is_available_on?(date) + # none of the lodging rooms has a confirmed reservation + StayItemDate.includes(:stay) + .where( + booking_date: date, + booked_item_type: StayItem::ROOM, + booked_item_id: rooms.pluck(:id), + stay: { status: "confirmed", draft: false } + ).none? + end + def average_booking_duration(start_date, end_date) selected_bookings = bookings_for_date_range(start_date, end_date) durations = selected_bookings.map do |booking| @@ -98,8 +152,15 @@ def bookings_for_date_range(start_date, end_date) bookings.where(status: "confirmed", from_date: start_date..end_date) end - def count_bookings(start_date, end_date) - bookings_for_date_range(start_date, end_date).count + def count_stays(start_date, end_date) + StayItemDate + .includes(:stay) + .where( + booking_date: start_date..end_date, + booked_item_type: StayItem::LODGING, + booked_item_id: 1, + stay: { status: "confirmed" }) + .pluck(:booking_date).uniq.count end def count_people(start_date, end_date) diff --git a/app/models/payment.rb b/app/models/payment.rb index bae5d83..a63a943 100644 --- a/app/models/payment.rb +++ b/app/models/payment.rb @@ -2,7 +2,7 @@ # # Table name: payments # -# booking_id :bigint not null +# booking_id :bigint # payment_method :string # status :string # deleted_at :datetime @@ -12,13 +12,16 @@ # stripe_checkout_session_id :string # stripe_payment_intent_id :string # id :uuid not null, primary key +# stay_id :bigint +# payment_request_id :bigint # class Payment < ApplicationRecord # notify ActiveRecord that the default sort order should be created_at self.implicit_order_column = :created_at - belongs_to :booking - + belongs_to :booking, optional: true + belongs_to :stay, optional: true + monetize :amount_cents, allow_nil: false has_paper_trail @@ -37,4 +40,5 @@ def paid? def pending? self.status == "pending" end + end diff --git a/app/models/product.rb b/app/models/product.rb index 01c62c8..b86bb17 100644 --- a/app/models/product.rb +++ b/app/models/product.rb @@ -13,6 +13,11 @@ # price_cents :integer # class Product < ApplicationRecord + + #v2 - stays + has_many :stay_items, as: :item + has_many :stays, through: :stay_items + has_paper_trail has_soft_deletion default_scope: true diff --git a/app/models/rental_item.rb b/app/models/rental_item.rb index 734f614..fb2372b 100644 --- a/app/models/rental_item.rb +++ b/app/models/rental_item.rb @@ -13,6 +13,12 @@ # updated_at :datetime not null # class RentalItem < ApplicationRecord + + + # v2 - stays + has_many :stay_items, as: :item + has_many :stays, through: :stay_items + has_paper_trail has_soft_deletion default_scope: true diff --git a/app/models/room.rb b/app/models/room.rb index fd9d8ce..f6c83f9 100644 --- a/app/models/room.rb +++ b/app/models/room.rb @@ -2,22 +2,51 @@ # # Table name: rooms # -# id :bigint not null, primary key -# name :string -# description :text -# created_at :datetime not null -# updated_at :datetime not null -# level :integer -# code :string -# deleted_at :datetime +# id :bigint not null, primary key +# name :string +# description :text +# created_at :datetime not null +# updated_at :datetime not null +# level :integer +# code :string +# deleted_at :datetime +# price_night_cents :integer default(0), not null # class Room < ApplicationRecord has_many :reservations has_many :lodging_rooms has_many :lodgings, through: :lodging_rooms + has_many :beds, dependent: :destroy + + # v2 - stays + has_many :stay_items, as: :item + has_many :stays, through: :stay_items + has_many :stay_item_dates, as: :booked_item has_soft_deletion default_scope: true + default_scope { order(name: :asc) } + + + + # price constant + # night_count => price + # currently same price for each room, add the room_id as an up hash key should rooms had different prices + PRICES = { + 1 => 12000, + 2 => 24000, + 3 => 36000, + 4 => 48000, + 5 => 60000, + 6 => 72000 + }.freeze + + def price(nights_count) + PRICES[nights_count] if nights_count + end + + + def name_with_level case level when 0 @@ -28,4 +57,21 @@ def name_with_level "#{name} (2ème étage)" end end + + def form_label + "#{name} (#{description})" + end + + def available?(start_date, end_date) + # check if the room is available + return false if StayItemDate.where(item_booked: self, booking_date: start_date..end_date).exists? + + # check if the corresponding lodgings are available + self.lodgings.each do |lod| + return false if StayItemDate.where(item_booked: lod, booking_date: start_date..end_date).exists? + end + + true + end + end diff --git a/app/models/space.rb b/app/models/space.rb index d97e531..4dc4480 100644 --- a/app/models/space.rb +++ b/app/models/space.rb @@ -9,15 +9,72 @@ # updated_at :datetime not null # code :string # deleted_at :datetime -# position :integer default(0) +# position :integer default(999) # class Space < ApplicationRecord has_many :space_reservations + #v2 - stays + has_many :stay_items, as: :bookable + has_soft_deletion default_scope: true default_scope -> { order(:position) } + + + # price constant + PRICES = { + 1 => { + "2h" => 11000, + "day" => 25000, + "evening" => 25000, + "fullday" => 38000, + "see_notes" => 0 + }, + 2 => { + "2h" => 5500, + "day" => 12000, + "evening" => 12000, + "fullday" => 19000, + "see_notes" => 0 + }, + 3 => { + "2h" => 14500, + "day" => 33500, + "evening" => 33500, + "fullday" => 49500, + "see_notes" => 0 + }, + 4 => { + "2h" => 4500, + "day" => 9500, + "evening" => 9500, + "fullday" => 15000, + "see_notes" => 0 + }, + 5 => { # TODO: set the prices + "2h" => 0, + "day" => 0, + "evening" => 0, + "fullday" => 0, + "see_notes" => 0 + }, + 6 => { # TODO: set the prices + "2h" => 0, + "day" => 0, + "evening" => 0, + "fullday" => 0, + "see_notes" => 0 + } + }.freeze + + + def price(duration) + PRICES[id][duration] if PRICES[id] + end + + def booked_on?(date) SpaceReservation.includes(:space_booking) .where( diff --git a/app/models/stay.rb b/app/models/stay.rb new file mode 100644 index 0000000..856fa9a --- /dev/null +++ b/app/models/stay.rb @@ -0,0 +1,252 @@ +# == Schema Information +# +# Table name: stays +# +# id :bigint not null, primary key +# user_id :bigint not null +# start_date :date +# end_date :date +# status :string +# created_at :datetime not null +# updated_at :datetime not null +# platform :string +# adults :integer +# children :integer +# babies :integer +# estimated_arrival :string +# departure_time :string +# token :string +# customer_id :bigint +# deleted_at :datetime +# comments :text +# notes :text +# draft :boolean default(TRUE) +# payment_status :string +# invoice_status :string +# group_name :string +# public_notes :text +# final_price_cents :integer default(0), not null +# legacy_booking_id :bigint +# +class Stay < ApplicationRecord + # PublicActivity + include PublicActivity::Model + tracked owner: Proc.new{ |controller, model| controller.current_user rescue nil } + + belongs_to :customer, optional: true + + has_many :stay_items, dependent: :destroy + has_many :lodgings, through: :stay_items, source: :item, source_type: 'Lodging' + has_many :rooms, through: :stay_items, source: :item, source_type: 'Room' + has_many :beds, through: :stay_items, source: :item, source_type: 'Bed' + has_many :experiences, through: :stay_items, source: :item, source_type: 'Experience' + has_many :rental_items, through: :stay_items, source: :item, source_type: 'RentalItem' + has_many :products, through: :stay_items, source: :item, source_type: 'Product' + has_many :spaces, through: :stay_items, source: :item, source_type: 'Space' + + has_many :payments, inverse_of: :stay do + def persisted + reject { |payment| !payment.persisted? } + end + end + has_many :stay_item_dates + + accepts_nested_attributes_for :customer + accepts_nested_attributes_for :payments, + allow_destroy: true, + reject_if: lambda { |attributes| attributes['amount'].to_f.zero? } + + has_soft_deletion default_scope: true + + has_paper_trail + + monetize :final_price_cents, allow_nil: true + + + scope :current_and_future, -> { where("end_date >= ? and draft = ? ", Date.today, false).order(start_date: :asc) } + scope :past, -> { where("end_date < ? and draft = ? ", Date.today, false).order(start_date: :desc) } + scope :draft_excluded, -> { where("draft = ?", false)} + + def self.generate_token + validity = Proc.new { |token| Stay.where(token: token).first.nil? } + begin + generated_token = SecureRandom.hex(5)[0, 5] + generated_token = generated_token.encode("UTF-8") + end while validity[generated_token] == false + generated_token + end + + def name + "#{self.customer&.firstname} #{self.customer&.lastname}" + end + + def nights_count + (self.end_date - self.start_date).to_i + end + + def canceled? + status == StayStatus::CANCELED + end + + def confirmed? + status == StayStatus::CONFIRMED + end + + def declined? + status == StayStatus::DECLINED + end + + def pending? + status == StayStatus::PENDING + end + + def current? + (start_date..end_date).cover?(Date.today) + end + + def from_airbnb? + platform == "airbnb" + end + + def from_web? + platform == "web" + end + + def has_options? + self.experiences.any? || self.spaces.any? + end + + def paid? + payment_status == "paid" + end + + def partially_paid? + payment_status == "partially_paid" + end + + def pending? + status == "pending" + end + + def set_payment_status + if self.payments.paid.sum(:amount_cents) >= self.final_price_cents + status = "paid" + elsif self.payments.paid.sum(:amount_cents) > 0.0 + status = "partially_paid" + else + status = "pending" + end + self.update(payment_status: status) + end + + def total_remaining_amount + self.final_price.to_f - total_payments_received + end + + def total_payments_received + payments.paid.to_a.sum {|p| (p.amount.to_f)} + end + + # Calculer le montant total de la réservation basé sur le prix de chaque stay_items + def total_reservation_amount + stay_items.to_a.sum { |item| item.calculated_price.to_f } + end + + def build_booked_item + self.stay_items.each do |item| + case item.item_type + when StayItem::LODGING + # the lodging is booked + StayItemDate.build_item_dates(self.id, item, item.item_id, StayItem::LODGING, true) + # the rooms of that lodgings are booked as well' + lod = Lodging.find(item.item_id) + lod.rooms.each do |room| + StayItemDate.build_item_dates(self.id, item, room.id, StayItem::ROOM) + # the beds of the rooms are marked as booked as well + room.beds.each do |bed| + StayItemDate.build_item_dates(self.id, item, bed.id, StayItem::BED) + end + end + when StayItem::ROOM + # the room is booked + StayItemDate.build_item_dates(self.id, item, item.item_id, StayItem::ROOM, true) + # the corresponding lodging is marked as booked as well + room = Room.find(item.item_id) + # the beds of this room are booked as well + room.beds.each do |bed| + StayItemDate.build_item_dates(self.id, item, bed.id, StayItem::BED) + end + when StayItem::BED + # the bed is booked + StayItemDate.build_item_dates(self.id, item, item.item_id, StayItem::BED, true) + when StayItem::EXPERIENCE + StayItemDate.build_item_dates(self.id, item, item.item_id, StayItem::EXPERIENCE, true) + when StayItem::SPACE + StayItemDate.build_item_dates(self.id, item, item.item_id, StayItem::SPACE, true) + end + end + rescue ActiveRecord::RecordNotUnique => e + raise e + end + + def rooms_by_date + rooms_hash = Stay.items_grouped_by_date(stay_items.where(item_type: StayItem::ROOM)) + + # if lodgings have been booked, the rooms shall also be the rooms that belongs to the lodging + rooms_from_lods = [] + stay_items.where(item_type: StayItem::LODGING).each do |lod| + Lodging.find(lod.item_id).rooms.each do |room| + rooms_from_lods << StayItem.new(stay_id: lod.stay_id, + start_date: lod.start_date, + end_date: lod.end_date, + item_id: room.id, + item_type: StayItem::ROOM) + end + end + rooms_hash = rooms_hash.merge(Stay.items_grouped_by_date(rooms_from_lods)) + rooms_hash + end + + def experiences_by_date + Stay.items_grouped_by_date(stay_items.where(item_type: StayItem::EXPERIENCE)) + end + + def products_by_date + Stay.items_grouped_by_date(stay_items.where(item_type: StayItem::PRODUCT)) + end + + def spaces_by_date + Stay.items_grouped_by_date(stay_items.where(item_type: StayItem::SPACE)) + end + + def rental_items_by_date + Stay.items_grouped_by_date(stay_items.where(item_type: StayItem::RENTAL_ITEM)) + end + + # Retourne true si le séjour contient au moins un hébergement de type Lodging + def has_lodging? + stay_items.where(item_type: StayItem::LODGING).exists? + end + + def self.items_grouped_by_date(_stay_items) + reservation_hash = Hash.new { |hash, key| hash[key] = [] } + + _stay_items.each do |stay_item| + (stay_item.start_date..stay_item.end_date).each do |date| + reservation_hash[date] << stay_item.item + end + end + reservation_hash.sort.to_h + end + + def self.stay_items_grouped_by_date(_stay_items) + reservation_hash = Hash.new { |hash, key| hash[key] = [] } + + _stay_items.each do |stay_item| + (stay_item.start_date..stay_item.end_date).each do |date| + reservation_hash[date] << stay_item + end + end + reservation_hash.sort.to_h + end +end diff --git a/app/models/stay_item.rb b/app/models/stay_item.rb new file mode 100644 index 0000000..d23e506 --- /dev/null +++ b/app/models/stay_item.rb @@ -0,0 +1,56 @@ +# == Schema Information +# +# Table name: stay_items +# +# id :bigint not null, primary key +# stay_id :bigint not null +# item_type :string not null +# item_id :bigint not null +# start_date :date not null +# end_date :date not null +# quantity :integer default(1) +# adults_count :integer +# children_count :integer +# duration :string +# created_at :datetime not null +# updated_at :datetime not null +# unit_price_cents :integer default(0), not null +# unit_price_currency :string default("EUR"), not null +# babies_count :integer +# calculated_price_cents :integer default(0), not null +# +class StayItem < ApplicationRecord + belongs_to :stay + belongs_to :item, polymorphic: true + has_many :stay_item_dates + + LODGING = 'Lodging' + ROOM = 'Room' + BED = 'Bed' + SPACE = 'Space' + EXPERIENCE = 'Experience' + PRODUCT = 'Product' + RENTAL_ITEM = 'RentalItem' + + ITEM_TYPE_ORDER = [SPACE, LODGING, ROOM, BED, EXPERIENCE, PRODUCT, RENTAL_ITEM] + + scope :order_by_item_type, -> { + order( + Arel.sql( + "CASE item_type " + + ITEM_TYPE_ORDER.map.with_index { |type, index| "WHEN '#{type}' THEN #{index}" }.join(" ") + + " END" + ), + ) + } + + monetize :calculated_price_cents, as: "calculated_price" + + def total_price + unit_price_cents * quantity + end + + def nights_count + (self.end_date - self.start_date).to_i + end +end diff --git a/app/models/stay_item_date.rb b/app/models/stay_item_date.rb new file mode 100644 index 0000000..24ab41d --- /dev/null +++ b/app/models/stay_item_date.rb @@ -0,0 +1,36 @@ +# == Schema Information +# +# Table name: stay_item_dates +# +# id :bigint not null, primary key +# booked_item_type :string not null +# booked_item_id :bigint not null +# booking_date :date not null +# stay_id :bigint not null +# created_at :datetime not null +# updated_at :datetime not null +# direct_book :boolean default(TRUE) +# stay_item_id :bigint +# +class StayItemDate < ApplicationRecord + belongs_to :booked_item, polymorphic: true + belongs_to :stay + belongs_to :stay_item + + def start_time + booking_date.to_time + end + + def self.build_item_dates(stay_id, stay_item, booked_item_id, booked_item_type, direct_book=false) + + (stay_item.start_date..stay_item.end_date).each do |date| + StayItemDate.create!(stay_id: stay_id, + booked_item_id: booked_item_id, + booked_item_type: booked_item_type, + booking_date: date, + stay_item_id: stay_item.id, + direct_book: direct_book) + end + end + +end diff --git a/app/models/stay_status.rb b/app/models/stay_status.rb new file mode 100644 index 0000000..346a064 --- /dev/null +++ b/app/models/stay_status.rb @@ -0,0 +1,9 @@ +module StayStatus + PENDING = 'pending' + CONFIRMED = 'confirmed' + CANCELED = 'canceled' + COMPLETED = 'completed' + DECLINED = 'declined' + + ALL_STATUSES = [PENDING, CONFIRMED, CANCELED, COMPLETED, DECLINED].freeze +end \ No newline at end of file diff --git a/app/presenters/components/menu_presenter.rb b/app/presenters/components/menu_presenter.rb index a17b53f..4b2fe82 100644 --- a/app/presenters/components/menu_presenter.rb +++ b/app/presenters/components/menu_presenter.rb @@ -133,6 +133,11 @@ def primary_left_items body: "Événements", url: events_path, active: @active_primary == 'events' + }, + { + body: "Clients", + url: customers_path, + active: @active_primary == 'customers' } ] end diff --git a/app/services/bills/create_service.rb b/app/services/bills/create_service.rb new file mode 100644 index 0000000..4d9a56c --- /dev/null +++ b/app/services/bills/create_service.rb @@ -0,0 +1,45 @@ +module Bills + + class CreateService < ServiceBase + + attr_reader :bill + attr_reader :stay + + def initialize(stay_id) + @stay = Stay.find(stay_id) + @bill = @stay.bills.new + @report_errors = true + end + + def run(params = {}) + context = { + params: params + } + + catch_error(context: context) do + run!(params) + end + end + + + def run!(params = {}) + @bill.attributes = bill_params(params) + return false if !@bill.valid? + @bill.save! + raise error_message if !error.nil? + true + end + + private + + def bill_params(params) + params + .require(:bill) + .permit( + :total_amount, + :payment_method + ) + end + + end +end diff --git a/app/services/concerns/billable.rb b/app/services/concerns/billable.rb new file mode 100644 index 0000000..fe1af82 --- /dev/null +++ b/app/services/concerns/billable.rb @@ -0,0 +1,6 @@ +module Billable + extend ActiveSupport::Concern + + + +end diff --git a/app/services/concerns/bookable.rb b/app/services/concerns/bookable.rb index 7ed1b85..5d2c3df 100644 --- a/app/services/concerns/bookable.rb +++ b/app/services/concerns/bookable.rb @@ -34,6 +34,86 @@ def available?(rooms) end end + # is the stay available? + def is_available? + @stay.stay_items.where(item_type: StayItem::LODGING).each do |lodging_item| + lod = Lodging.find(lodging_item.item_id) + lod.rooms.each do |room| + beds = room.beds + if StayItemDate.includes(:stay) + .where(booking_date: (lodging_item.start_date)..(lodging_item.end_date-1.day), + booked_item_type: StayItem::BED, + booked_item_id: [beds.collect{|b| b.id}], + stay: {status: "confirmed", draft: false, deleted_at: nil}) + .any? + set_error_message("Le gîte #{lod.name} n'est pas disponible à cette date.") + return false + end + + end + + end + + @stay.stay_items.where(item_type: StayItem::ROOM).each do |room_item| + room = Room.find(room_item.item_id) + beds = room.beds + if StayItemDate.includes(:stay) + .where(booking_date: (room_item.start_date)..(room_item.end_date-1.day), + booked_item_type: StayItem::BED, + booked_item_id: [beds.collect{|b| b.id}], + stay: {status: "confirmed", draft: false, deleted_at: nil}) + .any? + set_error_message("La chambre #{room.name} n'est pas disponible à cette date.") + return false + end + + end + + @stay.stay_items.where(item_type: StayItem::BED).each do |bed_item| + bed = Bed.find(bed_item.item_id) + if StayItemDate.includes(:stay) + .where(booking_date: (bed_item.start_date)..(bed_item.end_date-1.day), + booked_item_type: StayItem::BED, + booked_item_id: [bed.id], + stay: {status: "confirmed", draft: false, deleted_at: nil}) + .any? + set_error_message("Le lit #{bed.name} n'est pas disponible à cette date.") + return false + end + + end + + @stay.stay_items.where(item_type: StayItem::EXPERIENCE).each do |experience_item| + experience = Experience.find(experience_item.item_id) + if StayItemDate.includes(:stay) + .where(booking_date: (experience_item.start_date)..(experience_item.end_date-1.day), + booked_item_type: StayItem::EXPERIENCE, + booked_item_id: [experience.id], + stay: {status: "confirmed", draft: false, deleted_at: nil}) + .any? + set_error_message("L'atelier #{experience.name} n'est pas disponible à cette date.") + return false + end + + end + + @stay.stay_items.where(item_type: StayItem::SPACE).each do |space_item| + space = Space.find(space_item.item_id) + if StayItemDate.includes(:stay) + .where(booking_date: (space_item.start_date)..(space_item.end_date-1.day), + booked_item_type: StayItem::SPACE, + booked_item_id: [space.id], + stay: {status: "confirmed", draft: false, deleted_at: nil}) + .any? + set_error_message("La salle #{space.name} n'est pas disponible à cette date.") + return false + end + + end + + true + end + def build_reservations(rooms) rooms.each do |room| (@booking.from_date..(@booking.to_date - 1.day)).each do |date| @@ -61,6 +141,31 @@ def get_rooms set_error_message("Merci de vérifier si vous avez sélectionné un type d'hébergement.") end + + def get_beds + + beds = [] + @stay.lodgings.each do |lod| + lod.rooms.each do |room| + beds.concat(room.beds) + end + end + + @stay.rooms.each do |room| + beds.concat(room.beds) + end + + beds.concat(@stay.beds) + + if beds.uniq.length != beds.length + set_error_message("Attention, vous avez placé 2 hébergements similaires dans la même réservation") + nil + end + + beds + + end + def notify_admin_on_create AdminMailer.booking_request(@booking).deliver_now end @@ -156,6 +261,8 @@ def booking_params(params) ) end + + def public_booking_params(params) params .require(:booking) diff --git a/app/services/customers/create_service.rb b/app/services/customers/create_service.rb new file mode 100644 index 0000000..38f5e72 --- /dev/null +++ b/app/services/customers/create_service.rb @@ -0,0 +1,72 @@ +module Customers + + class CreateService < ServiceBase + + attr_reader :customer + + def initialize + @customer = Customer.new + @report_errors = true + end + + def run(params = {}) + context = { + params: params + } + catch_error(context: context) do + run!(params) + end + end + + def run!(params = {}) + # Handle both direct customer creation and nested attributes from stays + if params[:customer].present? + # Direct customer creation + @customer.attributes = customer_params(params) + @customer.save! + elsif params[:stay]&.dig(:customer_attributes).present? + # Nested attributes from stay creation + @customer = Customer.find_or_initialize_by(email: params[:stay][:customer_attributes][:email]) + if @customer.new_record? + @customer.attributes = nested_customer_params(params) + @customer.save! + end + else + raise ArgumentError, "No customer data provided" + end + end + + private + + def customer_params(params) + params + .require(:customer) + .permit( + :firstname, + :lastname, + :email, + :phone, + :notes, + :company_name, + :vat_number, + :street, + :number, + :box, + :postcode, + :city, + :country + ) + end + + def nested_customer_params(params) + params + .require(:stay).require(:customer_attributes) + .permit( + :firstname, + :lastname, + :email, + :phone + ) + end + end +end diff --git a/app/services/customers/merge_duplicates_service.rb b/app/services/customers/merge_duplicates_service.rb new file mode 100644 index 0000000..7860305 --- /dev/null +++ b/app/services/customers/merge_duplicates_service.rb @@ -0,0 +1,97 @@ +class Customers::MergeDuplicatesService < ServiceBase + attr_reader :master_customer, :duplicate_customers + + def run(master_customer_id:, duplicate_ids:) + context = { + master_customer_id: master_customer_id, + duplicate_ids: duplicate_ids + } + + catch_error(context: context) do + run!(master_customer_id: master_customer_id, duplicate_ids: duplicate_ids) + end + end + + def run!(master_customer_id:, duplicate_ids:) + validate_parameters(master_customer_id, duplicate_ids) + load_customers(master_customer_id, duplicate_ids) + merge_customers + true + end + + private + + def validate_parameters(master_customer_id, duplicate_ids) + if master_customer_id.blank? + set_error_message("Il faut sélectionner un client maître") + return false + end + + if duplicate_ids.empty? + set_error_message("Il faut sélectionner au moins un client à supprimer") + return false + end + + if duplicate_ids.include?(master_customer_id.to_s) + set_error_message("Le client maître ne peut pas être dans la liste des doublons à supprimer") + return false + end + + true + end + + def load_customers(master_customer_id, duplicate_ids) + @master_customer = Customer.find(master_customer_id) + @duplicate_customers = Customer.where(id: duplicate_ids) + + if @duplicate_customers.count != duplicate_ids.length + set_error_message("Certains clients à supprimer n'ont pas été trouvés") + return false + end + + true + end + + def merge_customers + ActiveRecord::Base.transaction do + # Transférer tous les séjours vers le client maître + @duplicate_customers.each do |duplicate_customer| + duplicate_customer.stays.update_all(customer_id: @master_customer.id) + + # Fusionner les informations manquantes du client maître + merge_customer_info(duplicate_customer) + end + + # Supprimer les clients doublons + @duplicate_customers.destroy_all + + # Logger l'opération + Rails.logger.info "Fusion de clients - Maître: #{@master_customer.id}, Supprimés: #{@duplicate_customers.pluck(:id)}" + end + end + + def merge_customer_info(duplicate_customer) + # Fusionner les informations manquantes + @master_customer.email = duplicate_customer.email if @master_customer.email.blank? && duplicate_customer.email.present? + @master_customer.phone = duplicate_customer.phone if @master_customer.phone.blank? && duplicate_customer.phone.present? + @master_customer.company_name = duplicate_customer.company_name if @master_customer.company_name.blank? && duplicate_customer.company_name.present? + @master_customer.vat_number = duplicate_customer.vat_number if @master_customer.vat_number.blank? && duplicate_customer.vat_number.present? + @master_customer.street = duplicate_customer.street if @master_customer.street.blank? && duplicate_customer.street.present? + @master_customer.number = duplicate_customer.number if @master_customer.number.blank? && duplicate_customer.number.present? + @master_customer.box = duplicate_customer.box if @master_customer.box.blank? && duplicate_customer.box.present? + @master_customer.postcode = duplicate_customer.postcode if @master_customer.postcode.blank? && duplicate_customer.postcode.present? + @master_customer.city = duplicate_customer.city if @master_customer.city.blank? && duplicate_customer.city.present? + @master_customer.country = duplicate_customer.country if @master_customer.country.blank? && duplicate_customer.country.present? + + # Combiner les notes + if duplicate_customer.notes.present? + if @master_customer.notes.present? + @master_customer.notes += "\n\n--- Fusionné depuis client #{duplicate_customer.id} ---\n#{duplicate_customer.notes}" + else + @master_customer.notes = duplicate_customer.notes + end + end + + @master_customer.save! + end +end \ No newline at end of file diff --git a/app/services/customers/update_service.rb b/app/services/customers/update_service.rb new file mode 100644 index 0000000..043106c --- /dev/null +++ b/app/services/customers/update_service.rb @@ -0,0 +1,48 @@ +module Customers + + class UpdateService < ServiceBase + + attr_reader :customer + + def initialize(customer:) + @customer = customer + @report_errors = true + end + + def run(params = {}) + context = { + params: params + } + catch_error(context: context) do + run!(params) + end + end + + def run!(params = {}) + @customer.attributes = customer_params(params) + @customer.save! + end + + private + + def customer_params(params) + params + .require(:customer) + .permit( + :firstname, + :lastname, + :email, + :phone, + :notes, + :company_name, + :vat_number, + :street, + :number, + :box, + :postcode, + :city, + :country + ) + end + end +end \ No newline at end of file diff --git a/app/services/payments/create_service.rb b/app/services/payments/create_service.rb index e8594d6..6351d48 100644 --- a/app/services/payments/create_service.rb +++ b/app/services/payments/create_service.rb @@ -1,11 +1,12 @@ module Payments class CreateService < ServiceBase - attr_reader :booking + #attr_reader :booking attr_reader :payment + attr_reader :reservation - def initialize(booking_id:) - @booking = Booking.find(booking_id) - @payment = @booking.payments.new + def initialize(reservation_type:, reservation_id:) + @reservation = get_reservation(reservation_type, reservation_id) + @payment = @reservation.payments.new @report_errors = true end @@ -24,7 +25,7 @@ def run!(params = {}) return false if !@payment.valid? set_status @payment.save! - @booking.set_payment_status + @reservation.set_payment_status raise error_message if !error.nil? true end @@ -52,5 +53,18 @@ def payment_params(params) :payment_method ) end + + def get_reservation(reservation_type, reservation_id) + case reservation_type + when 'Booking' + Booking.find(reservation_id) + when 'Stay' + Stay.find(reservation_id) + else + raise ArgumentError, "Unsupported reservation type: #{reservation_type}" + end + end + + end end diff --git a/app/services/payments/destroy_service.rb b/app/services/payments/destroy_service.rb index ff3d4b3..b54517d 100644 --- a/app/services/payments/destroy_service.rb +++ b/app/services/payments/destroy_service.rb @@ -1,11 +1,11 @@ module Payments class DestroyService < ServiceBase - attr_reader :booking + attr_reader :reservation attr_reader :payment def initialize(payment_id:) @payment = Payment.find(payment_id) - @booking = @payment.booking + @reservation = @payment.booking.nil? ? @payment.stay : @payment.booking @report_errors = true end @@ -21,7 +21,7 @@ def run(params = {}) def run!(params = {}) @payment.soft_delete!(validate: false) - @booking.set_payment_status + @reservation.set_payment_status raise error_message if !error.nil? true end diff --git a/app/services/payments/pay_service.rb b/app/services/payments/pay_service.rb index 43e263e..eb8782a 100644 --- a/app/services/payments/pay_service.rb +++ b/app/services/payments/pay_service.rb @@ -31,11 +31,11 @@ def run!(params = {}) def stripe_checkout StripeService.instance.create_checkout_session( client_reference_id: @payment.id, - success_url: public_booking_url(@payment.booking.token), - cancel_url: public_booking_url(@payment.booking.token), + success_url: public_stay_url(@payment.stay.token), + cancel_url: public_stay_url(@payment.stay.token), item: { id: @payment.id, - name: "Réservation ##{@payment.booking.token}", + name: "Séjour ##{@payment.stay.token}", amount: @payment.amount_cents } ) diff --git a/app/services/payments/update_service.rb b/app/services/payments/update_service.rb index 19fe843..6572f0f 100644 --- a/app/services/payments/update_service.rb +++ b/app/services/payments/update_service.rb @@ -1,11 +1,11 @@ module Payments class UpdateService < ServiceBase - attr_reader :booking + attr_reader :reservation attr_reader :payment def initialize(payment_id:) @payment = Payment.find(payment_id) - @booking = @payment.booking + @reservation = @payment.booking.nil? ? @payment.stay : @payment.booking @report_errors = true end @@ -24,7 +24,7 @@ def run!(params = {}) return false if !@payment.valid? ActiveRecord::Base.transaction do @payment.save! - @booking.set_payment_status + @reservation.set_payment_status end raise error_message if !error.nil? true diff --git a/app/services/stay_items/create_service.rb b/app/services/stay_items/create_service.rb new file mode 100644 index 0000000..a0c08b9 --- /dev/null +++ b/app/services/stay_items/create_service.rb @@ -0,0 +1,84 @@ +module StayItems + class CreateService < ServiceBase + attr_reader :stay + attr_reader :stay_item + + def initialize(stay_id:) + @stay = Stay.find(stay_id) + @stay_item = @stay.stay_items.new + @report_errors = true + end + + def run(params = {}) + context = { + params: params, + } + + catch_error(context: context) do + run!(params) + end + end + + def run!(params = {}) + stay_item.attributes = stay_item_params(params) + stay_item.item_type = stay_item.item_type.camelize + + # temp fix for setting start_date (can't be null in database) + stay_item.start_date = Date.today if stay_item.start_date.nil? + + # set the unit price from the item + set_item_price(stay_item) + + stay_item.end_date = stay_item.start_date if stay_item.end_date.nil? + stay_item.save! + true + end + + + private + + def set_item_price(stay_item) + + case stay_item.item_type + when StayItem::LODGING + item = Lodging.find(stay_item.item_id) + stay_item.unit_price_cents = item.price_night_cents + when StayItem::ROOM + item = Room.find(stay_item.item_id) + stay_item.unit_price_cents = item.price_night_cents + when StayItem::BED + item = Bed.find(stay_item.item_id) + stay_item.unit_price_cents = item.price_cents + when StayItem::EXPERIENCE + item = Experience.find(stay_item.item_id) + stay_item.unit_price_cents = item.price_cents # TODO: price or fixed_price? + when StayItem::PRODUCT + item = Product.find(stay_item.item_id) + stay_item.unit_price_cents = item.price_cents + when StayItem::RENTAL_ITEM + item = RentalItem.find(stay_item.item_id) + stay_item.unit_price_cents = item.price_cents + end + + end + + + def stay_item_params(params) + params + .require(:stay_item) + .permit( + :item_id, + :item_type, + :quantity, + :start_date, + :end_date, + :unit_price, + :adults_count, + :children_count, + :babies_count, + :duration, + :calculated_price + ) + end + end +end diff --git a/app/services/stay_items/update_service.rb b/app/services/stay_items/update_service.rb new file mode 100644 index 0000000..4b8a4c0 --- /dev/null +++ b/app/services/stay_items/update_service.rb @@ -0,0 +1,51 @@ +module StayItems + class UpdateService < ServiceBase + + attr_reader :stay_item + + def initialize(stay_item_id:) + @stay_item = StayItem.find(stay_item_id) + @report_errors = true + end + + def run(params = {}) + context = { + params: params, + } + + catch_error(context: context) do + run!(params) + end + end + + def run!(params = {}) + stay_item.attributes = stay_item_params(params) + stay_item.save! + true + end + + + private + + def stay_item_params(params) + params + .require(:stay_item) + .permit( + :item_id, + :item_type, + :quantity, + :start_date, + :end_date, + :unit_price, + :adults_count, + :children_count, + :babies_count, + :duration, + :adults, + :children, + :babies, + :calculated_price + ) + end + end +end diff --git a/app/services/stay_prices/calculation_service.rb b/app/services/stay_prices/calculation_service.rb new file mode 100644 index 0000000..a37bffd --- /dev/null +++ b/app/services/stay_prices/calculation_service.rb @@ -0,0 +1,123 @@ +module StayPrices + class CalculationService < ServiceBase + + include Bookable + + attr_reader :stay, :stay_item, :item, :amount + + + + + + def initialize + @report_errors = true + end + + def run(params = {}) + context = { + params: params + } + + catch_error(context: context) do + run!(params) + end + end + + def run!(params = {}) + + if params[:item_type].present? + + method_name = "calculate_price_for_#{params[:item_type].downcase}" + + if respond_to?(method_name, true) + @amount = send(method_name, params) + else + raise ArgumentError, "Invalid item type: #{params[:item_type]}" + end + end + true + + end + + + private + + def calculate_price_for_product(params) + product = Product.find(params[:item_id]) + price = 0 + if product + price = BigDecimal(product.price_cents) * BigDecimal(params[:quantity]) + end + (price/100) + end + + def calculate_price_for_experience(params) + experience = Experience.find(params[:item_id]) + price = 0 + price_for_adult = 0 + price_for_children = 0 + if experience + # TODO: how to include duration in the calcul of the price? + adult_count = params[:adult_count] || 0 + children_count = params[:children_count] || 0 + price_for_adult = BigDecimal(experience.price_cents) * BigDecimal(adult_count) unless adult_count == 0 + price_for_children = BigDecimal(experience.price_cents) * BigDecimal(children_count) * 0.5 unless children_count == 0 + price = price_for_adult + price_for_children + end + (price/100) + end + + def calculate_price_for_rentalitem(params) + item = RentalItem.find(params[:item_id]) + price = 0 + if item + start_date = Date.parse(params[:start_date]) + end_date = Date.parse(params[:end_date]) + quantity = params[:quantity].to_i + night_count = (end_date - start_date).to_i + price = BigDecimal(night_count) * BigDecimal(quantity) * BigDecimal(item.price_cents) unless quantity == 0 || night_count == 0 + end + (price/100) + end + + + def calculate_price_for_space(params) + item = Space.find(params[:item_id]) + price = 0 + duration = params[:duration] + price = item.price(duration) + (price/100) + end + + def calculate_price_for_lodging(params) + calculate_price_for(Lodging.find(params[:item_id]), params) + + end + + def calculate_price_for_room(params) + calculate_price_for(Room.find(params[:item_id]), params) + end + + def calculate_price_for_bed(params) + calculate_price_for(Bed.find(params[:item_id]), params) + end + + def calculate_price_for(item, params) + price = 0 + if item + start_date = Date.parse(params[:start_date]) + end_date = Date.parse(params[:end_date]) + night_count = (end_date - start_date).to_i + if night_count >0 && night_count <= 6 + price = item.price(night_count) + elsif night_count > 6 + price = item.price(1) * night_count + end + end + (price/100) + end + + end +end + + diff --git a/app/services/stays/create_service.rb b/app/services/stays/create_service.rb new file mode 100644 index 0000000..6d39ba5 --- /dev/null +++ b/app/services/stays/create_service.rb @@ -0,0 +1,38 @@ +module Stays + + class CreateService < ServiceBase + include Bookable + include Subscribable + + attr_reader :stay + + def initialize + @stay = Stay.new + @report_errors = true + end + + def run(params = {}) + context = { + params: params + } + + catch_error(context: context) do + run!(params) + end + end + + def run!(params = {}) + customer_service = Customers::CreateService.new + customer_service.run(params) + @stay.attributes = stay_params(params).except(:customer_attributes) + @stay.customer = customer_service.customer + @stay.generate_token + @stay.user_id = User.first.id + @stay.save! + return false if !@stay.valid? + @stay.build_booked_item + raise error_message if !error.nil? + true + end + end +end diff --git a/app/services/stays/update_service.rb b/app/services/stays/update_service.rb new file mode 100644 index 0000000..cc7835c --- /dev/null +++ b/app/services/stays/update_service.rb @@ -0,0 +1,86 @@ +module Stays + class UpdateService < ServiceBase + include Bookable + + attr_reader :stay + + def initialize(stay_id:) + @report_errors = true + @stay = Stay.find_by!(id: stay_id) + end + + def run(params = {}) + context = { + params: params, + stay: stay&.attributes + } + + catch_error(context: context) do + run!(params) + end + end + + def run!(params = {}) + @stay.attributes = stay_params(params) + return false if !@stay.valid? + ActiveRecord::Base.transaction do + # delete previous reservations as we will re-create them + @stay.stay_item_dates.destroy_all + begin + if is_available? + @stay.build_booked_item + @stay.draft = false + end + rescue ActiveRecord::RecordNotUnique => e + set_error_message("L'un des éléments d'hébergement est déjà occupé à ces dates. Merci de vérifier.") + raise error_message + true + end + + end + customer_service = Customers::CreateService.new + customer_service.run(params) + @stay.customer = customer_service.customer + @stay.save! + @stay.set_payment_status + raise error_message if !error.nil? + true + end + + private + + def stay_params(params) + params + .require(:stay) + .permit( + :adults, + :children, + :babies, + :departure_time, + :estimated_arrival, + :start_date, + :end_date, + :status, + :platform, + :group_name, + :notes, + :public_notes, + :invoice_status, + :final_price, + customer_attributes: [ + :id, + :firstname, + :lastname, + :email, + :phone + ], + payments_attributes: [ + :id, + :amount, + :payment_method, + :_destroy + ] + ) + end + end +end diff --git a/app/views/accounting/index.html.slim b/app/views/accounting/index.html.slim index 9ea4c5a..5795809 100644 --- a/app/views/accounting/index.html.slim +++ b/app/views/accounting/index.html.slim @@ -13,82 +13,33 @@ h2.mt-8.mb-4.text-lg.font-medium.tracking-tight.text-gray-900 Hébergements dont th.py-3.px-3.w-2[scope="col"] |   th.py-3.px-6.w-64[scope="col"] - | Nom + | Client th.py-3.px-6.hidden.md:table-cell[scope="col"] | Du th.py-3.px-6.hidden.md:table-cell[scope="col"] | Au - th.py-3.px-6[scope="col"] - | Hébergement th.py-3.px-6[scope="col"] | Paiement - tbody - - @bookings.each do |booking| - tr.border-b(class="#{booking.tr_class}") - td.py-4.px-6(class="#{booking.tr_border_class}") - = booking.status_emoji - td.py-4.px-6.font-medium.text-gray-900.whitespace-nowrap[scope="row"] - strong(class="#{booking.declined? ? "line-through" : nil}") - = link_to booking.group_or_name, - booking_path(booking), - class: "text-blue-500 border-b-2 border-blue-200 hover:text-blue-700 focus:text-blue-700" - .md:hidden.mt-1.text-gray-900 - = booking.date_range - td.py-4.px-6.hidden.md:table-cell - = booking.from_date(format: :ddmmyyyy) - td.py-4.px-6.hidden.md:table-cell - = booking.to_date(format: :ddmmyyyy) - td.py-4.px-6.space-x-1 - - if !booking.lodging.nil? - = booking.lodging_badge(font_size: "sm") - - else - = booking.rooms_badges(font_size: "sm") - td.py-4.px-6 - - if booking.confirmed? - = booking.payment_status - -h2.mt-8.mb-4.text-lg.font-medium.tracking-tight.text-gray-900 Réservations d'espaces dont le montant est à définir - -.overflow-x-auto.relative - table.w-full.text-sm.text-left.text-gray-500 - thead.text-xs.text-gray-700.uppercase.bg-gray-50 - tr - th.py-3.px-3.w-2[scope="col"] - |   - th.py-3.px-6.w-48[scope="col"] - | Nom - th.py-3.px-6.hidden.md:table-cell[scope="col"] - | Dates th.py-3.px-6[scope="col"] - | Période - th.py-3.px-6[scope="col"] - | Espaces - th.py-3.px-6[scope="col"] - | Paiement + |   tbody - - @space_bookings.each do |space_booking| - tr.border-b(class="#{space_booking.tr_class}") - td.py-4.px-6(class="#{space_booking.tr_border_class}") - = space_booking.status_emoji + - @stays_without_price.each do |stay| + tr.border-b(class="#{stay.tr_class}") + td.py-4.px-6(class="#{stay.tr_border_class}") + = stay.status_emoji td.py-4.px-6.font-medium.text-gray-900.whitespace-nowrap[scope="row"] - strong(class="#{space_booking.declined? ? "line-through" : nil}") - = link_to space_booking.group_or_name, - space_booking_path(space_booking), - class: "text-blue-500 border-b-2 border-blue-200 hover:text-blue-700 focus:text-blue-700" - .md:hidden.mt-1.text-gray-900 - = space_booking.date_range + = link_to(stay.customer&.decorate&.display_name || stay.name, customer_path(stay.customer), class: "claudy-link") td.py-4.px-6.hidden.md:table-cell - = space_booking.from_date(format: :ddmmyyyy) - br - | → #{space_booking.to_date(format: :ddmmyyyy)} + = l(stay.start_date, format: :ddmmyyyy) + td.py-4.px-6.hidden.md:table-cell + = l(stay.end_date, format: :ddmmyyyy) td.py-4.px-6 - = space_booking.duration - td.py-4.px-6.space-x-1 - = space_booking.spaces_badges(font_size: "xs") + - if stay.confirmed? + = stay.payment_status td.py-4.px-6 - = space_booking.payment_badge + = link_to "Modifier", edit_stay_path(stay), class: "claudy-link" -h2.mt-8.mb-4.text-lg.font-medium.tracking-tight.text-gray-900 Hébergements avec facture à fournir +h2.mt-8.mb-4.text-lg.font-medium.tracking-tight.text-gray-900 Séjours avec facture à fournir .overflow-x-auto.relative table.w-full.text-sm.text-left.text-gray-500 @@ -97,77 +48,27 @@ h2.mt-8.mb-4.text-lg.font-medium.tracking-tight.text-gray-900 Hébergements avec th.py-3.px-3.w-2[scope="col"] |   th.py-3.px-6.w-64[scope="col"] - | Nom + | Client th.py-3.px-6.hidden.md:table-cell[scope="col"] - | Du + | Du th.py-3.px-6.hidden.md:table-cell[scope="col"] - | Au + | Au th.py-3.px-6[scope="col"] - | Hébergement + | Paiement th.py-3.px-6[scope="col"] - | Paiement + |   tbody - - @bookings_with_requested_invoice.each do |booking| - tr.border-b(class="#{booking.tr_class}") - td.py-4.px-6(class="#{booking.tr_border_class}") - = booking.status_emoji + - @stays_with_requested_invoice.each do |stay| + tr.border-b(class=stay.tr_class) + td.py-4.px-6(class=stay.tr_border_class) + = stay.status_emoji td.py-4.px-6.font-medium.text-gray-900.whitespace-nowrap[scope="row"] - strong(class="#{booking.declined? ? "line-through" : nil}") - = link_to booking.group_or_name, - booking_path(booking), - class: "text-blue-500 border-b-2 border-blue-200 hover:text-blue-700 focus:text-blue-700" - .md:hidden.mt-1.text-gray-900 - = booking.date_range - td.py-4.px-6.hidden.md:table-cell - = booking.from_date(format: :ddmmyyyy) + = link_to(stay.customer&.decorate&.display_name || stay.name, customer_path(stay.customer), class: "text-blue-500 border-b-2 border-blue-200 hover:text-blue-700 focus:text-blue-700") td.py-4.px-6.hidden.md:table-cell - = booking.to_date(format: :ddmmyyyy) - td.py-4.px-6.space-x-1 - - if !booking.lodging.nil? - = booking.lodging_badge(font_size: "sm") - - else - = booking.rooms_badges(font_size: "sm") - td.py-4.px-6 - - if booking.confirmed? - = booking.payment_status - -h2.mt-8.mb-4.text-lg.font-medium.tracking-tight.text-gray-900 Réservations d'espaces avec facture à fournir - -.overflow-x-auto.relative - table.w-full.text-sm.text-left.text-gray-500 - thead.text-xs.text-gray-700.uppercase.bg-gray-50 - tr - th.py-3.px-3.w-2[scope="col"] - |   - th.py-3.px-6.w-48[scope="col"] - | Nom - th.py-3.px-6.hidden.md:table-cell[scope="col"] - | Dates - th.py-3.px-6[scope="col"] - | Période - th.py-3.px-6[scope="col"] - | Espaces - th.py-3.px-6[scope="col"] - | Paiement - tbody - - @space_bookings_with_requested_invoice.each do |space_booking| - tr.border-b(class="#{space_booking.tr_class}") - td.py-4.px-6(class="#{space_booking.tr_border_class}") - = space_booking.status_emoji - td.py-4.px-6.font-medium.text-gray-900.whitespace-nowrap[scope="row"] - strong(class="#{space_booking.declined? ? "line-through" : nil}") - = link_to space_booking.group_or_name, - space_booking_path(space_booking), - class: "text-blue-500 border-b-2 border-blue-200 hover:text-blue-700 focus:text-blue-700" - .md:hidden.mt-1.text-gray-900 - = space_booking.date_range + = l(stay.start_date, format: :ddmmyyyy) td.py-4.px-6.hidden.md:table-cell - = space_booking.from_date(format: :ddmmyyyy) - br - | → #{space_booking.to_date(format: :ddmmyyyy)} + = l(stay.end_date, format: :ddmmyyyy) td.py-4.px-6 - = space_booking.duration - td.py-4.px-6.space-x-1 - = space_booking.spaces_badges(font_size: "xs") + = stay.payment_status td.py-4.px-6 - = space_booking.payment_badge + = link_to "Modifier", edit_stay_path(stay), class: "claudy-link" diff --git a/app/views/bookings/show.html.slim b/app/views/bookings/show.html.slim index 2cf35af..5e0d907 100644 --- a/app/views/bookings/show.html.slim +++ b/app/views/bookings/show.html.slim @@ -123,7 +123,7 @@ ul.mt-2.mb-4.grid.grid-cols-1.gap-5.sm:grid-cols-2.sm:gap-6.lg:grid-cols-4[role= tbody.bg-white(id="payments-#{@booking.id}") = render PaymentDecorator.decorate_collection(@booking.payments) tfoot - = render partial: "payments/sum", locals: { booking: @booking } + = render partial: "payments/sum", locals: { reservation: @booking } dl.my-4.px-6 .py-2.sm:grid.sm:grid-cols-3.sm:gap-4 diff --git a/app/views/bookings/show/_details.html.slim b/app/views/bookings/show/_details.html.slim index bd6801a..2f3f370 100644 --- a/app/views/bookings/show/_details.html.slim +++ b/app/views/bookings/show/_details.html.slim @@ -30,7 +30,7 @@ - if booking.confirmed? p - strong Votre séjour aux 4 Sources + strong Votre réservation aux 4 Sources - else p strong Votre demande de réservation diff --git a/app/views/customers/_alphabetical_pagination.html.slim b/app/views/customers/_alphabetical_pagination.html.slim new file mode 100644 index 0000000..0b705c8 --- /dev/null +++ b/app/views/customers/_alphabetical_pagination.html.slim @@ -0,0 +1,6 @@ +.flex.flex-wrap.justify-center.items-center.gap-1.my-4 + - letters.each do |letter| + - if letter == current_letter + span.inline-block.bg-blue-600.text-white.font-bold.rounded.px-3.py-1.shadow-sm = letter + - else + = link_to letter, customers_path(letter: letter), class: "inline-block bg-gray-100 hover:bg-blue-200 text-blue-700 font-semibold rounded px-3 py-1 transition" \ No newline at end of file diff --git a/app/views/customers/_form.html.slim b/app/views/customers/_form.html.slim new file mode 100644 index 0000000..ae236d3 --- /dev/null +++ b/app/views/customers/_form.html.slim @@ -0,0 +1,88 @@ +/ Informations personnelles +.border-l-8.border-indigo-700.-ml-4.pl-4.md:p-0.md:m-0.md:border-0.grid.grid-cols-1.md:grid-cols-3.md:gap-4 + .md:border-r-8.md:border-indigo-700.md:pr-4.md:text-right + = section_heading_tw heading: "Informations personnelles" + + .col-span-2.mb-4.space-y-6.sm:space-y-5 + = f.text_field :firstname, + label: "Prénom", + class: "md:w-1/2 lg:w-2/3" + + = f.text_field :lastname, + label: "Nom", + class: "md:w-1/2 lg:w-2/3" + + = f.text_field :phone, + label: "Numéro de téléphone", + class: "md:w-1/2 lg:w-1/3" + + = f.email_field :email, + label: "Adresse email", + class: "md:w-2/3 lg:w-1/2" + +hr + +/ Données professionnelles +.border-l-8.border-green-700.-ml-4.pl-4.md:p-0.md:m-0.md:border-0.grid.grid-cols-1.md:grid-cols-3.md:gap-4 + .md:border-r-8.md:border-green-700.md:pr-4.md:text-right + = section_heading_tw heading: "Données professionnelles" + + .col-span-2.mb-4.space-y-6.sm:space-y-5 + = f.text_field :company_name, + label: "Nom de l'entreprise", + class: "md:w-2/3 lg:w-3/4" + + = f.text_field :vat_number, + label: "Numéro de TVA", + class: "md:w-1/2 lg:w-1/3" + +hr + +/ Adresse +.border-l-8.border-purple-700.-ml-4.pl-4.md:p-0.md:m-0.md:border-0.grid.grid-cols-1.md:grid-cols-3.md:gap-4 + .md:border-r-8.md:border-purple-700.md:pr-4.md:text-right + = section_heading_tw heading: "Adresse" + + .col-span-2.mb-4.space-y-6.sm:space-y-5 + .space-y-6.md:space-y-0.md:flex.md:space-x-6 + = f.text_field :street, + label: "Rue", + class: "md:w-2/3" + + = f.text_field :number, + label: "Numéro", + class: "md:w-1/3" + + .space-y-6.md:space-y-0.md:flex.md:space-x-6 + = f.text_field :box, + label: "Boîte", + class: "md:w-1/3" + + = f.text_field :postcode, + label: "Code postal", + class: "md:w-1/3" + + = f.text_field :city, + label: "Ville", + class: "md:w-1/2 lg:w-2/3" + + = f.select :country, + options_for_select(country_options, f.object.country), + { label: "Pays" }, + { class: "md:w-1/2 lg:w-1/3" } + +hr + +/ Notes +.border-l-8.border-yellow-700.-ml-4.pl-4.md:p-0.md:m-0.md:border-0.grid.grid-cols-1.md:grid-cols-3.md:gap-4 + .md:border-r-8.md:border-yellow-700.md:pr-4.md:text-right + = section_heading_tw heading: "Notes" + + .col-span-2.mb-4.space-y-6.sm:space-y-5 + = f.label :notes, + "Notes internes" + = f.text_area :notes, + rows: 4, + class: "block w-full rounded-md border-0 py-1.5 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" + +hr \ No newline at end of file diff --git a/app/views/customers/_table.html.slim b/app/views/customers/_table.html.slim new file mode 100644 index 0000000..95179d9 --- /dev/null +++ b/app/views/customers/_table.html.slim @@ -0,0 +1,39 @@ +.overflow-x-auto.relative + table.w-full.text-sm.text-left.text-gray-500 + thead.text-xs.text-gray-700.uppercase.bg-gray-50 + tr + th.py-3.px-6[scope="col"] + | Nom / Entreprise + th.py-3.px-6.hidden.md:table-cell[scope="col"] + | Contact + th.py-3.px-6.text-center[scope="col"] + | Séjours confirmés + th.py-3.px-6.text-right[scope="col"] + | Chiffre d'affaires + th.py-1.px-1[scope="col"] + | Actions + tbody + - customers.each do |customer| + tr.bg-white.border-b.hover:bg-gray-50 + td.py-4.px-6.font-medium.text-gray-900 + = link_to customer.display_name, + customer_path(customer), + class: "text-blue-600 hover:text-blue-800 hover:underline" + td.py-4.px-6.hidden.md:table-cell.text-gray-600 + = customer.contact_info + td.py-4.px-6.text-center + - if customer.confirmed_stays_count > 0 + span.inline-flex.items-center.px-2.5.py-0.5.rounded-full.text-xs.font-medium.bg-blue-100.text-blue-800 + = customer.confirmed_stays_count + - else + span.inline-flex.items-center.px-2.5.py-0.5.rounded-full.text-xs.font-medium.text-gray-500 + = customer.confirmed_stays_count + td.py-4.px-6.text-right.font-medium.text-green-600 + = customer.total_revenue + td.py-1.px-1 + = link_to 'Voir', + customer_path(customer), + class: "text-blue-500 hover:text-blue-700 mr-2" + = link_to 'Modifier', + edit_customer_path(customer), + class: "text-orange-500 hover:text-orange-700" \ No newline at end of file diff --git a/app/views/customers/duplicates.html.slim b/app/views/customers/duplicates.html.slim new file mode 100644 index 0000000..ab33fff --- /dev/null +++ b/app/views/customers/duplicates.html.slim @@ -0,0 +1,127 @@ +- content_for :page_header do + = render "layouts/components/page_header", + title: "Gestion des doublons clients" + +- if @duplicate_groups.empty? + .text-center.py-16 + .text-gray-400.text-lg Aucun doublon détecté ! + .text-gray-500.mt-2 Tous les clients semblent uniques. + = link_to "Retour à la liste", customers_path, class: "mt-4 inline-block bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600" + +- else + .mb-6 + .bg-blue-50.border-l-4.border-blue-400.p-4 + .flex + .flex-shrink-0 + .text-blue-400 ℹ️ + .ml-3 + .text-sm.text-blue-700 + strong Détection automatique + p.mt-1 #{@duplicate_groups.length} groupe(s) de doublons détecté(s). Pour chaque groupe, sélectionnez le client à conserver (maître) et cochez les doublons à supprimer. + + - @duplicate_groups.each_with_index do |duplicate_group, group_index| + .mb-8.bg-white.shadow.rounded-lg.overflow-hidden + .bg-gray-50.px-6.py-4.border-b + h3.text-lg.font-medium.text-gray-900 + | Groupe #{group_index + 1} : #{duplicate_group.first.full_name} + .text-sm.text-gray-600.mt-1 + | #{duplicate_group.length} clients trouvés + + = form_with url: merge_duplicates_customers_path, method: :patch, local: true, class: "p-6" do |f| + .space-y-4 + - duplicate_group.each do |customer| + - customer = customer.decorate + .flex.items-center.p-4.border.rounded-lg(class="#{customer.email.present? ? 'border-green-200 bg-green-50' : 'border-gray-200'}") + .flex.items-start.space-x-3.flex-1 + .flex.flex-col.space-y-2 + / Radio pour sélectionner le client maître + .flex.items-center + = f.radio_button :master_customer_id, customer.id, + id: "master_#{group_index}_#{customer.id}", + class: "h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300" + = f.label "master_#{group_index}_#{customer.id}", + "Client maître", + class: "ml-2 text-sm font-medium text-blue-600" + + / Checkbox pour marquer comme doublon à supprimer + .flex.items-center + = check_box_tag "duplicate_ids[]", customer.id, false, + id: "duplicate_#{group_index}_#{customer.id}", + class: "h-4 w-4 text-red-600 focus:ring-red-500 border-gray-300 rounded" + = label_tag "duplicate_#{group_index}_#{customer.id}", + "Supprimer (doublon)", + class: "ml-2 text-sm font-medium text-red-600" + + .flex-1.ml-4 + .font-medium.text-gray-900 + = link_to customer.display_name, customer_path(customer), class: "text-blue-600 hover:text-blue-800 hover:underline" + .text-sm.text-gray-600.mt-1 + - if customer.email.present? + .text-green-600 + strong Email: + = customer.email + - else + .text-red-500 Pas d'email + + - if customer.phone.present? + div + strong Téléphone: + = customer.phone + + div + strong Créé le: + = l(customer.created_at, format: :short_with_year) + + div.font-medium + => customer.stays.count + | séjour(s) + + .mt-6.pt-4.border-t.border-gray-200 + .text-sm.text-gray-500.mb-4 + | ⚠️ Les séjours des clients supprimés seront transférés vers le client maître. + br + | Les informations manquantes du client maître seront complétées. + + = f.submit "Fusionner ce groupe", + class: "bg-red-600 text-white px-4 py-2 rounded hover:bg-red-700 font-medium", + onclick: "return confirm('Êtes-vous sûr de vouloir fusionner ces clients ? Cette action est irréversible.')" + + .mt-8.text-center + = link_to "Retour à la liste des clients", customers_path, + class: "text-blue-500 hover:text-blue-700" + +/ JavaScript pour éviter les conflits entre radio et checkbox +javascript: + document.addEventListener('DOMContentLoaded', function() { + // Gérer les conflits entre radio buttons (maître) et checkboxes (doublon) + const forms = document.querySelectorAll('form'); + + forms.forEach(form => { + const radios = form.querySelectorAll('input[type="radio"][name="master_customer_id"]'); + const checkboxes = form.querySelectorAll('input[type="checkbox"][name="duplicate_ids[]"]'); + + radios.forEach(radio => { + radio.addEventListener('change', function() { + if (this.checked) { + // Décocher la checkbox correspondante si elle est cochée + const correspondingCheckbox = form.querySelector(`input[type="checkbox"][value="${this.value}"]`); + if (correspondingCheckbox) { + correspondingCheckbox.checked = false; + } + } + }); + }); + + checkboxes.forEach(checkbox => { + checkbox.addEventListener('change', function() { + if (this.checked) { + // Décocher le radio button correspondant s'il est sélectionné + const correspondingRadio = form.querySelector(`input[type="radio"][value="${this.value}"]`); + if (correspondingRadio) { + correspondingRadio.checked = false; + } + } + }); + }); + }); + }); \ No newline at end of file diff --git a/app/views/customers/edit.html.slim b/app/views/customers/edit.html.slim new file mode 100644 index 0000000..7a7f88d --- /dev/null +++ b/app/views/customers/edit.html.slim @@ -0,0 +1,13 @@ +- content_for :page_header do + = render 'layouts/components/page_header', + title: "Modifier le client", + secondary: @customer.decorate.display_name + += form_with model: @customer, + url: customer_path(@customer) do |f| + .space-y-8.divide-y.divide-gray-200.sm:space-y-5 + .space-y-6.sm:space-y-5 + = render "form", f: f + + = f.actions do + = f.submit "Mettre à jour" \ No newline at end of file diff --git a/app/views/customers/index.html.slim b/app/views/customers/index.html.slim new file mode 100644 index 0000000..24aae0a --- /dev/null +++ b/app/views/customers/index.html.slim @@ -0,0 +1,14 @@ +- content_for :page_header do + = render "layouts/components/page_header", + title: "Clients", + links: [ \ + link_to("Nouveau client", new_customer_path, class: "btn-page-header"), \ + link_to("Gérer les doublons", duplicates_customers_path, class: "btn-page-header bg-orange-500 hover:bg-orange-600 text-white") \ + ] + += render "customers/alphabetical_pagination", letters: @letters, current_letter: @current_letter + += render "customers/table", + customers: @customers + += render "customers/alphabetical_pagination", letters: @letters, current_letter: @current_letter \ No newline at end of file diff --git a/app/views/customers/new.html.slim b/app/views/customers/new.html.slim new file mode 100644 index 0000000..4835404 --- /dev/null +++ b/app/views/customers/new.html.slim @@ -0,0 +1,12 @@ +- content_for :page_header do + = render 'layouts/components/page_header', + title: "Nouveau client" + += form_with model: @customer, + url: customers_path do |f| + .space-y-8.divide-y.divide-gray-200.sm:space-y-5 + .space-y-6.sm:space-y-5 + = render "form", f: f + + = f.actions do + = f.submit "Enregistrer" \ No newline at end of file diff --git a/app/views/customers/show.html.slim b/app/views/customers/show.html.slim new file mode 100644 index 0000000..93ea95f --- /dev/null +++ b/app/views/customers/show.html.slim @@ -0,0 +1,219 @@ +- content_for :page_header do + = render 'layouts/components/page_header', + title: @customer.display_name, + secondary: @customer.contact_info, + links: [ \ + link_to("Modifier", edit_customer_path(@customer), class: "btn-page-header") \ + ] + +.grid.sm:grid-cols-1.md:grid-cols-3.gap-4 + .md:col-span-2.space-y-4 + / Informations de contact + .overflow-hidden.bg-white.shadow.sm:rounded-lg + .px-4.py-5.sm:px-6 + h3.text-lg.font-medium.leading-6.text-gray-900 + | Informations de contact + .border-t.border-gray-200 + dl + - if @customer.company_name.present? + .px-4.py-5.sm:grid.sm:grid-cols-3.sm:gap-4.sm:px-6 + dt.text-sm.font-medium.text-gray-500 + | Entreprise + dd.mt-1.text-sm.text-gray-900.sm:col-span-2.sm:mt-0 + = @customer.company_name + - if @customer.contact_person_info.present? + .px-4.py-5.sm:grid.sm:grid-cols-3.sm:gap-4.sm:px-6 + dt.text-sm.font-medium.text-gray-500 + | Personne de contact + dd.mt-1.text-sm.text-gray-900.sm:col-span-2.sm:mt-0 + = @customer.contact_person_info + - else + .px-4.py-5.sm:grid.sm:grid-cols-3.sm:gap-4.sm:px-6 + dt.text-sm.font-medium.text-gray-500 + | Nom + dd.mt-1.text-sm.text-gray-900.sm:col-span-2.sm:mt-0 + = @customer.lastname.presence || "(non renseigné)" + .px-4.py-5.sm:grid.sm:grid-cols-3.sm:gap-4.sm:px-6 + dt.text-sm.font-medium.text-gray-500 + | Prénom + dd.mt-1.text-sm.text-gray-900.sm:col-span-2.sm:mt-0 + = @customer.firstname.presence || "(non renseigné)" + .px-4.py-5.sm:grid.sm:grid-cols-3.sm:gap-4.sm:px-6 + dt.text-sm.font-medium.text-gray-500 + | Téléphone + dd.mt-1.text-sm.text-gray-900.sm:col-span-2.sm:mt-0 + - if @customer.phone.present? + = link_to @customer.phone, "tel:#{@customer.phone}", class: "text-blue-600 hover:text-blue-800" + - else + | (non renseigné) + .px-4.py-5.sm:grid.sm:grid-cols-3.sm:gap-4.sm:px-6 + dt.text-sm.font-medium.text-gray-500 + | Adresse e-mail + dd.mt-1.text-sm.text-gray-900.sm:col-span-2.sm:mt-0 + - if @customer.email.present? + = link_to @customer.email, "mailto:#{@customer.email}", class: "text-blue-600 hover:text-blue-800" + - else + | (non renseigné) + - if @customer.formatted_address.present? + .px-4.py-5.sm:grid.sm:grid-cols-3.sm:gap-4.sm:px-6 + dt.text-sm.font-medium.text-gray-500 + | Adresse + dd.mt-1.text-sm.text-gray-900.sm:col-span-2.sm:mt-0 + = @customer.formatted_address + + / Notes + - if @customer.notes.present? + .overflow-hidden.bg-yellow-100.shadow.sm:rounded-lg + .px-4.py-5.sm:px-6 + h3.text-lg.font-medium.leading-6.text-yellow-900 + | Notes + .border-t.border-yellow-200.px-6.py-4 + .text-yellow-800 + = simple_format @customer.notes + + / Détail des séjours + .overflow-hidden.bg-white.shadow.sm:rounded-lg + .px-4.py-5.sm:px-6 + h3.text-lg.font-medium.leading-6.text-gray-900 + | Séjours + span.ml-2.text-sm.text-gray-500 + | (#{@customer.stays.count} total) + - if @customer.stays.any? + .border-t.border-gray-200 + .overflow-x-auto.relative + table.w-full.text-sm.text-left.text-gray-500 + thead.text-xs.text-gray-700.uppercase.bg-gray-50 + tr + th.py-3.px-6[scope="col"] Dates + th.py-3.px-6[scope="col"] Statut + th.py-3.px-6[scope="col"] Montant + th.py-3.px-6[scope="col"] Actions + tbody + - StayDecorator.decorate_collection(@customer.stays.order(start_date: :desc)).each do |stay| + tr.bg-white.border-b.hover:bg-gray-50 + td.py-4.px-6 + = link_to stay_path(stay), class: "text-blue-600 hover:text-blue-800 hover:underline" do + .font-medium + = stay.date_range + .text-gray-500.text-xs + = pluralize(stay.nights_count, "nuitée") + td.py-4.px-6 + - case stay.status + - when "confirmed" + span.inline-flex.items-center.px-2.5.py-0.5.rounded-full.text-xs.font-medium.bg-green-100.text-green-800 + | Confirmé + - when "pending" + span.inline-flex.items-center.px-2.5.py-0.5.rounded-full.text-xs.font-medium.bg-yellow-100.text-yellow-800 + | En attente + - when "declined" + span.inline-flex.items-center.px-2.5.py-0.5.rounded-full.text-xs.font-medium.bg-red-100.text-red-800 + | Refusé + - when "canceled" + span.inline-flex.items-center.px-2.5.py-0.5.rounded-full.text-xs.font-medium.bg-gray-100.text-gray-800 + | Annulé + - else + span.inline-flex.items-center.px-2.5.py-0.5.rounded-full.text-xs.font-medium.bg-gray-100.text-gray-800 + = stay.status + td.py-4.px-6.font-medium + - if stay.final_price_cents.present? + = number_to_currency(stay.final_price_cents / 100.0) + - else + span.text-gray-400 (non renseigné) + td.py-4.px-6 + = link_to 'Voir', stay_path(stay), class: "text-blue-500 hover:text-blue-700" + - else + .px-6.py-4.text-gray-500 + | Aucun séjour enregistré + + / Liste des paiements + .overflow-hidden.bg-white.shadow.sm:rounded-lg + .px-4.py-5.sm:px-6 + h3.text-lg.font-medium.leading-6.text-gray-900 + | Paiements + - if @customer.vat_number.present? + .px-4.py-2.border-b.border-gray-200.bg-gray-50 + .text-sm.text-gray-600 + | Numéro de TVA : + span.font-medium = @customer.vat_number + - if @customer.stays.joins(:payments).any? + .border-t.border-gray-200 + .overflow-x-auto.relative + table.w-full.text-sm.text-left.text-gray-500 + thead.text-xs.text-gray-700.uppercase.bg-gray-50 + tr + th.py-3.px-6[scope="col"] Date + th.py-3.px-6[scope="col"] Séjour + th.py-3.px-6[scope="col"] Montant + th.py-3.px-6[scope="col"] Statut + tbody + - StayDecorator.decorate_collection(@customer.stays.joins(:payments).includes(:payments).order('payments.created_at DESC')).each do |stay| + - stay.payments.each do |payment| + tr.bg-white.border-b.hover:bg-gray-50 + td.py-4.px-6 + = l(payment.created_at.to_date, format: :short_with_year) + td.py-4.px-6 + = link_to stay_path(stay), class: "text-blue-600 hover:text-blue-800 hover:underline" do + = stay.date_range + td.py-4.px-6.font-medium + = number_to_currency(payment.amount_cents / 100.0) + td.py-4.px-6 + - case payment.status + - when "paid" + span.inline-flex.items-center.px-2.5.py-0.5.rounded-full.text-xs.font-medium.bg-green-100.text-green-800 + | Payé + - when "pending" + span.inline-flex.items-center.px-2.5.py-0.5.rounded-full.text-xs.font-medium.bg-yellow-100.text-yellow-800 + | En attente + - else + span.inline-flex.items-center.px-2.5.py-0.5.rounded-full.text-xs.font-medium.bg-gray-100.text-gray-800 + = payment.status + - else + .px-6.py-4.text-gray-500 + | Aucun paiement enregistré + + .space-y-4 + / Statistiques rapides + .space-y-4 + .bg-white.overflow-hidden.shadow.rounded-lg + .p-5 + .flex.items-center + .flex-shrink-0 + .w-8.h-8.bg-blue-500.rounded-md.flex.items-center.justify-center + span.text-white.font-semibold.text-sm + = @customer.confirmed_stays_count + .ml-5.w-0.flex-1 + dl + dt.text-sm.font-medium.text-gray-500.truncate + | Séjours confirmés + dd.text-lg.font-medium.text-gray-900 + = @customer.stays_status_summary + + .bg-white.overflow-hidden.shadow.rounded-lg + .p-5 + .flex.items-center + .flex-shrink-0 + .w-8.h-8.bg-green-500.rounded-md.flex.items-center.justify-center + span.text-white.font-semibold.text-xs + | € + .ml-5.w-0.flex-1 + dl + dt.text-sm.font-medium.text-gray-500.truncate + | Chiffre d'affaires + dd.text-lg.font-medium.text-gray-900 + = @customer.total_revenue + + - if @customer.latest_stay + .bg-white.overflow-hidden.shadow.rounded-lg + .p-5 + .flex.items-center + .flex-shrink-0 + .w-8.h-8.bg-purple-500.rounded-md.flex.items-center.justify-center + span.text-white.font-semibold.text-xs + | 📅 + .ml-5.w-0.flex-1 + dl + dt.text-sm.font-medium.text-gray-500.truncate + | Dernier séjour + dd.text-sm.font-medium.text-gray-900 + = link_to stay_path(@customer.latest_stay), class: "text-blue-600 hover:text-blue-800 hover:underline" do + = l(@customer.latest_stay.start_date, format: :short_with_year) \ No newline at end of file diff --git a/app/views/devise/sessions/new.html.slim b/app/views/devise/sessions/new.html.slim index 06b0785..e4f2cbb 100644 --- a/app/views/devise/sessions/new.html.slim +++ b/app/views/devise/sessions/new.html.slim @@ -2,9 +2,11 @@ .flex.flex-1.flex-col.justify-center.px-4.py-12.sm:px-6.lg:flex-none.lg:px-20.xl:px-24 .mx-auto.w-full.max-w-sm.lg:w-96 div - img.h-10.w-auto[src="https://tailwindui.com/img/logos/mark.svg?color=indigo&shade=600" alt="Claudy"] - h2.mt-8.text-4xl.font-bold.leading-9.tracking-tight.text-stone-900.font-caveat - | Me connecter à Claudy + = vite_image_tag "images/les-4-sources-logo-seul.png", + class: "h-10 w-auto", + alt: "Les 4 Sources" + h2.mt-8.text-4xl.font-bold.leading-9.tracking-tight.text-4s-main.font-caveat + | Bienvenue chez Claudy .mt-10 div = simple_form_for(resource, as: resource_name, url: session_path(resource_name), html: { class: "space-y-6" }) do |f| @@ -31,7 +33,7 @@ autocomplete: "current-password", class: "block w-full rounded-md border-0 py-1.5 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-indigo-600 sm:text-sm sm:leading-6" \ } - .flex.items-center.justify-between + / .flex.items-center.justify-between .flex.items-center input#remember-me.h-4.w-4.rounded.border-gray-300.text-indigo-600.focus:ring-indigo-600[name="remember-me" type="checkbox" checked="checked"] label.ml-3.block.text-sm.leading-6.text-gray-700[for="remember-me"] @@ -43,6 +45,6 @@ button.flex.w-full.justify-center.rounded-md.bg-indigo-600.px-3.py-1.5.text-sm.font-semibold.leading-6.text-white.shadow-sm.hover:bg-indigo-500.focus-visible:outline.focus-visible:outline-2.focus-visible:outline-offset-2.focus-visible:outline-indigo-600[type="submit"] | Me connecter .relative.hidden.w-0.flex-1.lg:block - = vite_image_tag "images/nest.jpg", + = vite_image_tag "images/orchard.jpg", class: "absolute inset-0 h-full w-full object-cover", alt: "" diff --git a/app/views/layouts/components/_navbar.html.slim b/app/views/layouts/components/_navbar.html.slim index fbb0ed5..b240171 100644 --- a/app/views/layouts/components/_navbar.html.slim +++ b/app/views/layouts/components/_navbar.html.slim @@ -1,8 +1,10 @@ nav.bg-white.border-gray-200.lg:px-4 .max-w-screen-xl.flex.flex-wrap.items-center.justify-between.mx-auto.p-4 a.flex.items-center[href="/"] - img.h-8.mr-3[src="https://flowbite.com/docs/images/logo.svg" alt="Flowbite Logo"] - span.self-center.text-2xl.font-semibold.whitespace-nowrap.dark:text-white + = vite_image_tag "images/les-4-sources-logo-seul.png", + class: "h-8 mr-1", + alt: "" + span.self-center.text-3xl.text-4s-main.font-semibold.whitespace-nowrap.font-caveat.dark:text-white | Claudy button.inline-flex.items-center.p-2.w-10.h-10.justify-center.text-sm.text-gray-500.rounded-lg.md:hidden.hover:bg-gray-100.focus:outline-none.focus:ring-2.focus:ring-gray-200.dark:text-gray-400.dark:hover:bg-gray-700.dark:focus:ring-gray-600[data-collapse-toggle="navbar-dropdown" type="button" aria-controls="navbar-dropdown" aria-expanded="false"] @@ -18,13 +20,13 @@ nav.bg-white.border-gray-200.lg:px-4 root_path, class: current_page?(root_path) ? "block py-2 pl-3 pr-4 text-white bg-blue-700 rounded md:bg-transparent md:text-blue-700 md:p-0" : "block py-2 pl-3 pr-4 text-gray-900 rounded hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0" li - = link_to "Hébergements", - bookings_path, - class: controller_name == "bookings" ? "block py-2 pl-3 pr-4 text-white bg-blue-700 rounded md:bg-transparent md:text-blue-700 md:p-0" : "block py-2 pl-3 pr-4 text-gray-900 rounded hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0" + = link_to "Séjours", + stays_path, + class: controller_name == "stays" ? "block py-2 pl-3 pr-4 text-white bg-blue-700 rounded md:bg-transparent md:text-blue-700 md:p-0" : "block py-2 pl-3 pr-4 text-gray-900 rounded hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0" li - = link_to "Espaces", - space_bookings_path, - class: controller_name == "space_bookings" ? "block py-2 pl-3 pr-4 text-white bg-blue-700 rounded md:bg-transparent md:text-blue-700 md:p-0" : "block py-2 pl-3 pr-4 text-gray-900 rounded hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0" + = link_to "Clients", + customers_path, + class: controller_name == "customers" ? "block py-2 pl-3 pr-4 text-white bg-blue-700 rounded md:bg-transparent md:text-blue-700 md:p-0" : "block py-2 pl-3 pr-4 text-gray-900 rounded hover:bg-gray-100 md:hover:bg-transparent md:border-0 md:hover:text-blue-700 md:p-0" li = link_to "Reporting", reports_path, diff --git a/app/views/layouts/public_sheet.html.slim b/app/views/layouts/public_sheet.html.slim index 602e3ef..42b6a23 100644 --- a/app/views/layouts/public_sheet.html.slim +++ b/app/views/layouts/public_sheet.html.slim @@ -12,19 +12,25 @@ html(lang="fr") = csp_meta_tag = vite_client_tag = vite_javascript_tag "public", "data-turbo-track": "reload" - body + body.bg-4s-main = render "layouts/public/components/google_tag" = turbo_frame_tag "modal" - if content_for?(:page_banner) - .text-center.gap-x-6.bg-indigo-600.py-2.5.px-6.sm:px-3.5.sm:before:flex-1 + .fixed.w-full.text-center.gap-x-6.bg-indigo-600.py-2.5.px-6.sm:px-3.5.sm:before:flex-1 = yield :page_banner + .pt-16 - .container.mx-auto.sm:px-6.lg:px-8 + .container.max-w-7xl.sm:px-6.lg:px-8 + .flex.justify-center + = vite_image_tag "images/les-4-sources-logo-white.png", + class: "w-48" + + .container.max-w-7xl.sm:px-6.lg:px-8 .grid.justify-items-center - .bg-white.md:shadow-2xl.md:rounded-lg.md:m-8.lg:w-3/4 - = vite_image_tag "images/hawthorns.jpg", - class: "w-full h-48 object-cover md:rounded-t-lg" + .bg-white.md:shadow-2xl.md:rounded-2xl.md:m-8.lg:w-3/4 + = vite_image_tag "images/les-4-sources-cover.jpg", + class: "w-full h-48 object-cover md:rounded-t-2xl" .p-4.pb-16.md:p-8.md:pb-16 - if content_for?(:page_header) diff --git a/app/views/notes/_note.html.slim b/app/views/notes/_note.html.slim index c26d327..8d45260 100644 --- a/app/views/notes/_note.html.slim +++ b/app/views/notes/_note.html.slim @@ -1,7 +1,7 @@ .mt-2.p-2.text-xs.font-medium.rounded-sm.shadow-md.border(id="#{dom_id note}" class="bg-#{note.color || 'yellow'}-200 border-#{note.color || 'yellow'}-300" data-controller="notes") = link_to edit_note_path(note), data: { turbo_frame: "modal" } do .block(data-notes-target="truncatedContent") - == simple_format(note.body.truncate(100)) + == simple_format(note.body.truncate(50)) .hidden(data-notes-target="fullContent") == simple_format(note.body) a(href="#" onclick="return false;" data-action="click->notes#showMore" data-notes-target="moreLink" class="flex -m-2 mt-1 text-center rounded-b-sm bg-#{note.color || 'yellow'}-300 px-2 py-1 text-#{note.color || 'yellow'}-900 hover:text-white hover:bg-#{note.color || 'yellow'}-500") diff --git a/app/views/pages/calendar.html.slim b/app/views/pages/calendar.html.slim index 5d0fc15..e029621 100644 --- a/app/views/pages/calendar.html.slim +++ b/app/views/pages/calendar.html.slim @@ -30,7 +30,7 @@ .absolute.top-0.left-0.w-full.h-full(data-popover-target="popover-event-#{event.id}" data-popover-trigger="click") / Space reservations - - if @grouped_space_reservations[date] + / - if @grouped_space_reservations[date] - @grouped_space_reservations[date].sort_by {|sr| sr.start_time }.group_by { |sr| sr.space_booking.id }.each do |grouped_space_reservations| - space_reservation = grouped_space_reservations.last.first - space_booking = SpaceBookingDecorator.new(space_reservation.space_booking) @@ -60,7 +60,7 @@ .absolute.top-0.left-0.w-full.h-full(data-popover-target="popover-space-reservation-#{space_reservation.id}" data-popover-trigger="click") / Reservations - - if @grouped_reservations[date] + / - if @grouped_reservations[date] - @grouped_reservations[date].sort_by {|r| r.start_time }.group_by { |r| r.booking.id }.each do |grouped_reservations| - reservation = grouped_reservations.last.first - booking = BookingDecorator.new(reservation.booking) @@ -88,6 +88,95 @@ = room_badge(reservation.room) .absolute.top-0.left-0.w-full.h-full(data-popover-target="popover-reservation-#{reservation.id}" data-popover-trigger="click") + / Stays + - if @grouped_stay_reservations[date] + - @grouped_stay_reservations[date].sort_by {|r| r.start_time }.group_by { |r| r.stay.id }.each do |grouped_reservations| + - reservation = grouped_reservations.last.first + - stay = StayDecorator.new(reservation.stay) + + .popover.absolute.z-10.invisible.inline-block.w-64.text-sm.font-light.text-gray-500.transition-opacity.duration-300.bg-white.border.border-gray-200.rounded-lg.shadow-sm.opacity-0[id="popover-reservation-#{reservation.id}" data-popover role="tooltip"] + = render "stays/popover", + stay: stay + div[data-popper-arrow] + + div(class="relative flex mt-2 py-1 px-2 hover:cursor-pointer hover:shadow-xl #{stay.calendar_class}") + .block + div + strong + = link_to stay.decorate.group_or_name.html_safe, + stay_path(stay), + class: "text-blue-700 hover:text-blue-900 focus:text-blue-900 text-base/6" + + span.text-gray-500 =< stay.dates_counter(date) + + - if !stay.lodgings.empty? + = stay.lodging_badge + - elsif !stay.rooms.empty? + .grid.grid-cols-3.gap-1 + - stay.rooms.each do |room| + = room_badge(room) + .absolute.top-0.left-0.w-full.h-full(data-popover-target="popover-reservation-#{reservation.id}" data-popover-trigger="click") + + / Stay Spaces + / - if @grouped_spaces[date] + - @grouped_spaces[date].sort_by {|sr| sr.booking_date }.group_by { |sr| sr.booked_item.id }.each do |grouped_space| + - space_reservation = grouped_space.last.first + - stay = StayDecorator.new(space_reservation.stay) + .popover.absolute.z-10.invisible.inline-block.w-64.text-sm.font-light.text-gray-500.transition-opacity.duration-300.bg-white.border.border-gray-200.rounded-lg.shadow-sm.opacity-0[id="popover-space-reservation-#{space_reservation.id}" data-popover role="tooltip"] + = render "stays/popover", + stay: stay + div[data-popper-arrow] + + div(class="relative flex mt-2 py-1 px-2 hover:cursor-pointer hover:shadow-xl #{stay.space_calendar_class}") + .block + div + strong + = link_to stay.decorate.group_or_name.html_safe, + stay_path(stay), + class: "text-blue-700 hover:text-blue-900 focus:text-blue-900 text-base/6" + -# if space_booking.event.presence + -# .text-gray-500.leading-none.mb-2.font-semibold = space_booking.event.name_with_color + + span.text-gray-500 =< stay.dates_counter(date) + + .grid.grid-cols-3.gap-1 + - grouped_space.last.each do |space_reservation| + = space_badge(space_reservation.booked_item) + + .block.text-gray-500 + = space_reservation.stay_item.decorate.duration + .absolute.top-0.left-0.w-full.h-full(data-popover-target="popover-space-reservation-#{space_reservation.id}" data-popover-trigger="click") + + / Stay Experiences + / - if @grouped_experiences[date] + - @grouped_experiences[date].sort_by {|sr| sr.booking_date }.group_by { |sr| sr.booked_item.id }.each do |grouped_experience| + - exp_reservation = grouped_experience.last.first + - stay = StayDecorator.new(exp_reservation.stay) + .popover.absolute.z-10.invisible.inline-block.w-64.text-sm.font-light.text-gray-500.transition-opacity.duration-300.bg-white.border.border-gray-200.rounded-lg.shadow-sm.opacity-0[id="popover-expreservation-#{exp_reservation.id}" data-popover role="tooltip"] + = render "stays/popover", + stay: stay + div[data-popper-arrow] + + div(class="relative flex mt-2 py-1 px-2 hover:cursor-pointer hover:shadow-xl #{stay.experience_calendar_class}") + .block + div + strong + = link_to stay.decorate.group_or_name.html_safe, + stay_path(stay), + class: "text-blue-700 hover:text-blue-900 focus:text-blue-900 text-base/6" + -# if space_booking.event.presence + -# .text-gray-500.leading-none.mb-2.font-semibold = space_booking.event.name_with_color + + span.text-gray-500 =< stay.dates_counter(date) + + .grid.grid-cols-3.gap-1 + - grouped_experience.last.each do |exp_reservation| + = experience_badge(exp_reservation.booked_item) + + .block.text-gray-500 + = exp_reservation.stay_item.decorate.duration + .absolute.top-0.left-0.w-full.h-full(data-popover-target="popover-space-reservation-#{exp_reservation.id}" data-popover-trigger="click") + / Activity feed .mt-8.pb-5.border-b.border-gray-200 h2.text-base.font-semibold.leading-6.text-gray-900 Activité récente diff --git a/app/views/pages/day.html.slim b/app/views/pages/day.html.slim index 806f1f2..80ff612 100644 --- a/app/views/pages/day.html.slim +++ b/app/views/pages/day.html.slim @@ -19,7 +19,7 @@ - if role.role_team.include?(human.id.to_s) = render "human_roles/edit", human: human, role: role, date: @date, human_roles: @human_roles - / Availabilities + / Stay Availabilities .mt-8.space-y-8 .flex .flex-none.w-48 @@ -28,7 +28,7 @@ .flex-initial .flex.space-x-4 - @lodgings.each do |lodging| - = lodging.availability_badge(@date) + = lodging.available_badge(@date) / Rooms .mt-4.flow-root @@ -136,6 +136,114 @@ span.inline-flex.items-center.rounded-full.bg-gray-100.px-3.py-0.5.text-sm.font-medium.text-gray-500 | Libre + + / Stay Rooms + .mt-4.flow-root + .-mx-4.-my-2.overflow-x-auto.sm:-mx-6.lg:-mx-8 + .inline-block.min-w-full.py-2.align-middle.sm:px-6.lg:px-8 + table.min-w-full.divide-y.divide-gray-300 + thead + tr + th.py-3.5.pl-4.pr-3.text-left.text-sm.font-semibold.text-gray-900.sm:pl-0[scope="col"] + | Chambre + th.w-2/3.px-3.py-3.5.text-left.text-sm.font-semibold.text-gray-900[scope="col"] + | Réservation + tbody.divide-y.divide-gray-200.bg-white + tr + td.divide-gray-500.py-2.pl-4.pr-3.text-sm.text-gray-500.bg-teal-100.sm:pl-0[colspan="3"] + span.px-2 + | Rez-de-chaussée + - @rooms.where(level: 0).each do |room| + tr + td.whitespace-nowrap.py-4.pl-4.pr-3.text-sm.sm:pl-0 + .flex.items-center + .h-10.w-10.flex-shrink-0 + = vite_image_tag "images/rooms/#{room.code}.jpg", alt: "", class: "h-10 w-10 rounded-full bg-gray-300" + .ml-4 + .font-medium.text-gray-900 + = room.name + .text-gray-500 + = room.description + td.px-3.py-4.text-sm.text-gray-500 + - if @stay_room_reservations.where(booked_item: room).any? + - @stay_room_reservations.where(booked_item: room).each do |reservation| + .grid.grid-cols-3.gap-4 + div + = link_to reservation.stay.group_or_name, + stay_path(reservation.stay), + data: { "turbo-frame": "_top" }, + class: "claudy-link" + div + = reservation.stay.people_emojis + .text-right + = reservation.stay.payment_status + - else + span.inline-flex.items-center.rounded-full.bg-gray-100.px-3.py-0.5.text-sm.font-medium.text-gray-500 + | Libre + tr + td.divide-gray-500.py-2.pl-4.pr-3.text-sm.text-gray-500.bg-teal-100.sm:pl-0[colspan="3"] + span.px-2 + | 1<sup>er</sup> étage + - @rooms.where(level: 1).each do |room| + tr + td.whitespace-nowrap.py-4.pl-4.pr-3.text-sm.sm:pl-0 + .flex.items-center + .h-10.w-10.flex-shrink-0 + = vite_image_tag "images/rooms/#{room.code}.jpg", alt: "", class: "h-10 w-10 rounded-full bg-gray-300" + .ml-4 + .font-medium.text-gray-900 + = room.name + .text-gray-500 + = room.description + td.px-3.py-4.text-sm.text-gray-500 + - if @stay_room_reservations.where(booked_item: room).any? + - @stay_room_reservations.where(booked_item: room).each do |reservation| + .grid.grid-cols-3.gap-4 + div + = link_to reservation.stay.group_or_name, + stay_path(reservation.stay), + data: { "turbo-frame": "_top" }, + class: "claudy-link" + div + = reservation.stay.people_emojis + .text-right + = reservation.stay.payment_status + - else + span.inline-flex.items-center.rounded-full.bg-gray-100.px-3.py-0.5.text-sm.font-medium.text-gray-500 + | Libre + tr + td.divide-gray-500.py-2.pl-4.pr-3.text-sm.text-gray-500.bg-teal-100.sm:pl-0[colspan="3"] + span.px-2 + | 2<sup>ème</sup> étage + - @rooms.where(level: 2).each do |room| + tr + td.whitespace-nowrap.py-4.pl-4.pr-3.text-sm.sm:pl-0 + .flex.items-center + .h-10.w-10.flex-shrink-0 + = vite_image_tag "images/rooms/#{room.code}.jpg", alt: "", class: "h-10 w-10 rounded-full bg-gray-300" + .ml-4 + .font-medium.text-gray-900 + = room.name + .text-gray-500 + = room.description + td.px-3.py-4.text-sm.text-gray-500 + - if @stay_room_reservations.where(booked_item: room).any? + - @stay_room_reservations.where(booked_item: room).each do |reservation| + .grid.grid-cols-3.gap-4 + div + = link_to reservation.stay.group_or_name, + stay_path(reservation.stay), + data: { "turbo-frame": "_top" }, + class: "claudy-link" + div + = reservation.stay.people_emojis + .text-right + = reservation.stay.payment_status + - else + span.inline-flex.items-center.rounded-full.bg-gray-100.px-3.py-0.5.text-sm.font-medium.text-gray-500 + | Libre + + / Spaces .mt-4.flow-root .-mx-4.-my-2.overflow-x-auto.sm:-mx-6.lg:-mx-8 @@ -175,3 +283,43 @@ - else span.inline-flex.items-center.rounded-full.bg-gray-100.px-3.py-0.5.text-sm.font-medium.text-gray-500 | Libre + + / Stay Spaces + .mt-4.flow-root + .-mx-4.-my-2.overflow-x-auto.sm:-mx-6.lg:-mx-8 + .inline-block.min-w-full.py-2.align-middle.sm:px-6.lg:px-8 + table.min-w-full.divide-y.divide-gray-300 + thead + tr + th.py-3.5.pl-4.pr-3.text-left.text-sm.font-semibold.text-gray-900.sm:pl-0[scope="col"] + | Espace + th.w-2/3.px-3.py-3.5.text-left.text-sm.font-semibold.text-gray-900[scope="col"] + | Réservation + tbody.divide-y.divide-gray-200.bg-white + - @spaces.each do |space| + tr + td.whitespace-nowrap.py-4.pl-4.pr-3.text-sm.sm:pl-0 + .flex.items-center + .h-10.w-10.flex-shrink-0.rounded-full.bg-gray-200 + / = vite_image_tag "images/rooms/#{room.code}.jpg", alt: "", class: "h-10 w-10 rounded-full bg-gray-300" + .ml-4 + .font-medium.text-gray-900 + = space.name + / .text-gray-500 + = room.description + td.px-3.py-4.text-sm.text-gray-500 + - if @stay_space_reservations.where(booked_item: space).any? + - @stay_space_reservations.where(booked_item: space).each do |space_reservation| + .grid.grid-cols-3.gap-4 + div + = link_to space_reservation.stay.group_or_name, + stay_path(space_reservation.stay), + data: { "turbo-frame": "_top" }, + class: "claudy-link" + div + = space_reservation.stay_item.decorate.duration + .text-right + = space_reservation.stay.payment_status + - else + span.inline-flex.items-center.rounded-full.bg-gray-100.px-3.py-0.5.text-sm.font-medium.text-gray-500 + | Libre diff --git a/app/views/pages/other_stays.html.slim b/app/views/pages/other_stays.html.slim new file mode 100644 index 0000000..3707081 --- /dev/null +++ b/app/views/pages/other_stays.html.slim @@ -0,0 +1,34 @@ += turbo_stream.update "stays-for-date-range" do + #toast-other-stays.fixed.right-5.bottom-5.flex.items-center.w-full.max-w-xs.p-4.space-x-4.text-gray-500.bg-white.divide-x.divide-gray-200.rounded-lg.shadow-lg.space-x(role="alert") + .text-sm.font-normal + .font-semibold + | Autres réservations à ces dates + + - if @grouped_reservations.empty? + p.text-gray-400 Aucune autre réservation + - else + - @grouped_reservations.each do |grouped_reservations| + .mt-2 + p = l(grouped_reservations.first) + + div + - grouped_reservations.last.sort_by {|r| r.start_time }.group_by { |r| r.stay.id }.each do |reservations| + - stay = StayDecorator.new(reservations.last.first.stay) + div(class="flex mt-2 py-1 px-2 #{stay.calendar_class}") + .block + div + strong + = link_to stay.decorate.name.html_safe, + stay_path(stay), + class: "text-blue-700 hover:text-blue-900 focus:text-blue-900" + - if stay.group_name.present? + .text-gray-500.leading-none.mb-2 = stay.group_name + + - if !stay.lodgings.empty? + = stay.lodging_badge + - elsif !stay.rooms.empty? + .grid.grid-cols-3.gap-1 + - stay.rooms.each do |room| + = room_badge(room) + + diff --git a/app/views/payments/_payment.html.slim b/app/views/payments/_payment.html.slim index 603c9fc..07f1cf9 100644 --- a/app/views/payments/_payment.html.slim +++ b/app/views/payments/_payment.html.slim @@ -8,37 +8,73 @@ data: { turbo_frame: "modal" }, class: "claudy-link ml-2 text-xs" .font-light = payment.payment_method -tr(id="#{dom_id payment}" class="#{payment.tr_class} hover:bg-sky-100") - td.py-4.px-6.text-center - = payment.payment_method_emoji - td.py-4.px-6 - = payment.created_at(format: :ddmmyyyy) - td.py-4.px-6 - = payment.amount - td.py-4.px-6 - = payment.status - td.py-4.px-6.font-medium.text-gray-900.whitespace-nowrap.hide-if-booking-page[scope="row"] - .inline-flex.space-x-2 - = link_to payment.booking_name, - booking_path(payment.booking_id), - class: "claudy-link" - = payment.booking_payment_status - .text-xs.text-gray-500 - = payment.booking_date_range - td.py-4.px-6.text-right - .space-x-2 - = link_to "détails", - payment_path(payment), - class: "claudy-link text-xs" - = link_to "modifier", - edit_booking_payment_path(payment.booking_id, payment), - data: { turbo_frame: "modal" }, - class: "claudy-link text-xs" +- if !@booking.nil? || controller_name == "payments" + tr(id="#{dom_id payment}" class="#{payment.tr_class}") + td.py-4.px-6.text-center + = payment.payment_method_emoji + td.py-4.px-6 + = payment.created_at(format: :ddmmyyyy) + td.py-4.px-6 + = payment.amount + td.py-4.px-6 + = payment.status + td.py-4.px-6.font-medium.text-gray-900.whitespace-nowrap.hide-if-booking-page[scope="row"] + .inline-flex.space-x-2 + = link_to payment.booking_name, + booking_path(payment.booking), + class: "claudy-link" + = payment.booking_payment_status + .text-xs.text-gray-500 + = payment.booking_date_range + td.py-4.px-6.text-right + .space-x-2 + = link_to "détails", + payment_path(payment), + class: "claudy-link text-xs" + = link_to "modifier", + edit_booking_payment_path(payment.booking, payment), + data: { turbo_frame: "modal" }, + class: "claudy-link text-xs" + + javascript: + // hide booking details on bookings#show + if (document.querySelector('#body-bookings_show')) { + document.querySelectorAll('.hide-if-booking-page').forEach((td) => { + td.classList.add('hidden'); + }); + } + +- if !@stay.nil? + tr(id="#{dom_id payment}" class="#{payment.tr_class}") + td.py-4.px-6.text-center + = payment.payment_method_emoji + td.py-4.px-6 + = payment.created_at(format: :ddmmyyyy) + td.py-4.px-6 + = payment.amount + td.py-4.px-6 + = payment.status + td.py-4.px-6.font-medium.text-gray-900.whitespace-nowrap.hide-if-booking-page[scope="row"] + .inline-flex.space-x-2 + = link_to payment.stay_name, + stay_path(payment.stay), + class: "claudy-link" + = payment.stay_payment_status + .text-xs.text-gray-500 + = payment.stay_date_range + td.py-4.px-6.text-right + .space-x-2 + = link_to "détails", + payment_path(payment), + class: "claudy-link text-xs" + = link_to "modifier", + edit_stay_payment_path(payment.stay, payment), + data: { turbo_frame: "modal" }, + class: "claudy-link text-xs" -javascript: - // hide booking details on bookings#show - if (document.querySelector('#body-bookings_show')) { - document.querySelectorAll('.hide-if-booking-page').forEach((td) => { - td.classList.add('hidden'); - }); - } \ No newline at end of file + javascript: + // hide booking details on stay#show + if (document.querySelector('#body-stays_show')) { + document.querySelectorAll('.hide-if-stay-page').forEach((td) => { + td.classList.add('hidden'); + } \ No newline at end of file diff --git a/app/views/payments/_reservation.html.slim b/app/views/payments/_reservation.html.slim new file mode 100644 index 0000000..424897e --- /dev/null +++ b/app/views/payments/_reservation.html.slim @@ -0,0 +1,19 @@ +- if !payment.booking.nil? + dd.mt-1.text-sm.text-gray-900.sm:col-span-2.sm:mt-0 + .inline-flex.space-x-2 + = link_to @payment.booking_name, + booking_path(payment.booking), + class: "claudy-link" + = payment.booking_payment_status + .text-xs.text-gray-500 + = payment.booking_date_range + +- if !payment.stay.nil? + dd.mt-1.text-sm.text-gray-900.sm:col-span-2.sm:mt-0 + .inline-flex.space-x-2 + = link_to payment.stay_name, + stay_path(payment.stay), + class: "claudy-link" + = payment.stay_payment_status + .text-xs.text-gray-500 + = payment.stay_date_range \ No newline at end of file diff --git a/app/views/payments/_stay_payments.html.slim b/app/views/payments/_stay_payments.html.slim new file mode 100644 index 0000000..c30130c --- /dev/null +++ b/app/views/payments/_stay_payments.html.slim @@ -0,0 +1,35 @@ + +tr(id="#{dom_id payment}" class="#{payment.tr_class}") + td.py-4.px-6.text-center + = payment.payment_method_emoji + td.py-4.px-6 + = payment.created_at(format: :ddmmyyyy) + td.py-4.px-6 + = payment.amount + td.py-4.px-6 + = payment.status + td.py-4.px-6.font-medium.text-gray-900.whitespace-nowrap.hide-if-stay-page[scope="row"] + .inline-flex.space-x-2 + = link_to payment.stay_name, + stay_path(payment.stay), + class: "claudy-link" + = payment.stay_payment_status + .text-xs.text-gray-500 + = payment.stay_date_range + td.py-4.px-6.text-right + .space-x-2 + = link_to "détails", + payment_path(payment), + class: "claudy-link text-xs" + = link_to "modifier", + edit_stay_payment_path(payment.stay, payment), + data: { turbo_frame: "modal" }, + class: "claudy-link text-xs" + +javascript: + // hide booking details on bookings#show + if (document.querySelector('#body-stays_show')) { + document.querySelectorAll('.hide-if-stay-page').forEach((td) => { + td.classList.add('hidden'); + }); + } \ No newline at end of file diff --git a/app/views/payments/_sum.html.slim b/app/views/payments/_sum.html.slim index 1faff9b..9092791 100644 --- a/app/views/payments/_sum.html.slim +++ b/app/views/payments/_sum.html.slim @@ -2,5 +2,5 @@ tr.border-t.border-t-gray-300 td   td   td.py-4.px-6.font-semibold - = @booking.payments_total + = reservation.payments_total td   diff --git a/app/views/payments/create.turbo_stream.slim b/app/views/payments/create.turbo_stream.slim index 436cf69..3368a64 100644 --- a/app/views/payments/create.turbo_stream.slim +++ b/app/views/payments/create.turbo_stream.slim @@ -1,9 +1,21 @@ -= turbo_stream.prepend "payments-#{@payment.booking_id}", - partial: "payments/payment", - locals: { payment: @payment } +- if @payment.booking + = turbo_stream.prepend "payments-#{@payment.booking_id}", + partial: "payments/payment", + locals: { payment: @payment } -= turbo_stream.replace_all ".booking-#{@payment.booking_id}-payment-status", - @payment.booking.payment_status + = turbo_stream.replace_all ".booking-#{@payment.booking_id}-payment-status", + @payment.booking.payment_status -= turbo_stream.replace "booking-#{@payment.booking_id}-payments-sum", - @payment.booking.payments_total \ No newline at end of file + = turbo_stream.replace "booking-#{@payment.booking_id}-payments-sum", + @payment.booking.payments_total + +- if @payment.stay + = turbo_stream.prepend "payments-#{@payment.stay_id}", + partial: "payments/stay_payments", + locals: { payment: @payment } + + = turbo_stream.replace_all ".stay-#{@payment.stay_id}-payment-status", + @payment.stay.payment_status + + = turbo_stream.replace "stauy-#{@payment.stay_id}-payments-sum", + @payment.stay.payments_total \ No newline at end of file diff --git a/app/views/payments/destroy.turbo_stream.slim b/app/views/payments/destroy.turbo_stream.slim index 071b050..75291c5 100644 --- a/app/views/payments/destroy.turbo_stream.slim +++ b/app/views/payments/destroy.turbo_stream.slim @@ -1,7 +1,17 @@ -= turbo_stream.remove @payment +- if @payment.booking + = turbo_stream.remove @payment -= turbo_stream.replace_all ".booking-#{@payment.booking_id}-payment-status", - @payment.booking.payment_status + = turbo_stream.replace_all ".booking-#{@payment.booking_id}-payment-status", + @payment.booking.payment_status -= turbo_stream.replace "booking-#{@payment.booking_id}-payments-sum", - @payment.booking.payments_total \ No newline at end of file + = turbo_stream.replace "booking-#{@payment.booking_id}-payments-sum", + @payment.booking.payments_total + +- if @payment.stay + = turbo_stream.remove @payment + + = turbo_stream.replace_all ".stay-#{@payment.stay_id}-payment-status", + @payment.stay.payment_status + + = turbo_stream.replace "stay-#{@payment.stay_id}-payments-sum", + @payment.stay.payments_total \ No newline at end of file diff --git a/app/views/payments/edit.html.slim b/app/views/payments/edit.html.slim index 6e772ed..ee417bb 100644 --- a/app/views/payments/edit.html.slim +++ b/app/views/payments/edit.html.slim @@ -1,16 +1,33 @@ -= render TurboModal::Component.new(title: "Paiement", width: :sm) do - = form_with model: [@booking, @payment] do |f| - .space-y-8.divide-y.divide-gray-200.sm:space-y-5 - .space-y-6.sm:space-y-5 - = render "form", f: f +- if @booking + = render TurboModal::Component.new(title: "Paiement", width: :sm) do + = form_with model: [@booking, @payment] do |f| + .space-y-8.divide-y.divide-gray-200.sm:space-y-5 + .space-y-6.sm:space-y-5 + = render "form", f: f - = f.actions do - = link_to "Supprimer ce paiement", - booking_payment_path(@booking, @payment), - class: "text-xs font-semibold text-red-500", - data: { \ - turbo_method: :delete, - action: "turbo-modal--component#closeDialog" \ - } - = f.submit "Enregistrer" - \ No newline at end of file + = f.actions do + = link_to "Supprimer ce paiement", + booking_payment_path(@booking, @payment), + class: "text-xs font-semibold text-red-500", + data: { \ + turbo_method: :delete, + action: "turbo-modal--component#closeDialog" \ + } + = f.submit "Enregistrer" + +- if @stay + = render TurboModal::Component.new(title: "Paiement", width: :sm) do + = form_with model: [@stay, @payment] do |f| + .space-y-8.divide-y.divide-gray-200.sm:space-y-5 + .space-y-6.sm:space-y-5 + = render "form", f: f + + = f.actions do + = link_to "Supprimer ce paiement", + stay_payment_path(@stay, @payment), + class: "text-xs font-semibold text-red-500", + data: { \ + turbo_method: :delete, + action: "turbo-modal--component#closeDialog" \ + } + = f.submit "Enregistrer" \ No newline at end of file diff --git a/app/views/payments/index.html.slim b/app/views/payments/index.html.slim index 8057493..18dda2a 100644 --- a/app/views/payments/index.html.slim +++ b/app/views/payments/index.html.slim @@ -20,4 +20,30 @@ | Actions tbody.bg-white - @payments.each do |payment| - = render partial: "payments/payment", locals: { payment: payment } + tr(id="#{dom_id payment}" class="#{payment.tr_class}") + td.py-4.px-6.text-center + = payment.payment_method_emoji + td.py-4.px-6 + = payment.created_at(format: :ddmmyyyy) + td.py-4.px-6 + = payment.amount + td.py-4.px-6 + = payment.status + td.py-4.px-6.font-medium.text-gray-900.whitespace-nowrap.hide-if-booking-page[scope="row"] + .inline-flex.space-x-2 + = link_to payment.stay_name, + stay_path(payment.stay), + class: "claudy-link" + = payment.stay_payment_status + .text-xs.text-gray-500 + = payment.stay_date_range + td.py-4.px-6.text-right + .space-x-2 + = link_to "détails", + payment_path(payment), + class: "claudy-link text-xs" + = link_to "modifier", + edit_stay_payment_path(payment.stay, payment), + data: { turbo_frame: "modal" }, + class: "claudy-link text-xs" + diff --git a/app/views/payments/new.html.slim b/app/views/payments/new.html.slim index ac68a47..8fab439 100644 --- a/app/views/payments/new.html.slim +++ b/app/views/payments/new.html.slim @@ -1,9 +1,19 @@ -= render TurboModal::Component.new(title: "Paiement", width: :sm) do - = form_with model: [@booking, @payment] do |f| - .space-y-8.divide-y.divide-gray-200.sm:space-y-5 - .space-y-6.sm:space-y-5 - = render "form", f: f +- if @booking + = render TurboModal::Component.new(title: "Paiement", width: :sm) do + = form_with model: [@booking, @payment] do |f| + .space-y-8.divide-y.divide-gray-200.sm:space-y-5 + .space-y-6.sm:space-y-5 + = render "form", f: f - = f.actions do - = f.submit "Enregistrer le paiement" - \ No newline at end of file + = f.actions do + = f.submit "Enregistrer le paiement" + +- if @stay + = render TurboModal::Component.new(title: "Paiement", width: :sm) do + = form_with model: [@stay, @payment] do |f| + .space-y-8.divide-y.divide-gray-200.sm:space-y-5 + .space-y-6.sm:space-y-5 + = render "form", f: f + + = f.actions do + = f.submit "Enregistrer le paiement" diff --git a/app/views/payments/show.html.slim b/app/views/payments/show.html.slim index c718364..2028c8f 100644 --- a/app/views/payments/show.html.slim +++ b/app/views/payments/show.html.slim @@ -57,14 +57,8 @@ dt.text-sm.font-medium.text-gray-500 | Réservation - dd.mt-1.text-sm.text-gray-900.sm:col-span-2.sm:mt-0 - .inline-flex.space-x-2 - = link_to @payment.booking_name, - booking_path(@payment.booking), - class: "claudy-link" - = @payment.booking_payment_status - .text-xs.text-gray-500 - = @payment.booking_date_range + = render partial: "reservation", + locals: { payment: @payment } dt.text-sm.font-medium.text-gray-500 | Stripe Checkout Session ID diff --git a/app/views/payments/update.turbo_stream.slim b/app/views/payments/update.turbo_stream.slim index b6461d4..c2989cd 100644 --- a/app/views/payments/update.turbo_stream.slim +++ b/app/views/payments/update.turbo_stream.slim @@ -1,9 +1,21 @@ -= turbo_stream.replace @payment, - partial: "payments/payment", - locals: { payment: @payment } +- if @payment.booking + = turbo_stream.replace @payment, + partial: "payments/payment", + locals: { payment: @payment } -= turbo_stream.replace_all ".booking-#{@payment.booking_id}-payment-status", - @payment.booking.payment_status + = turbo_stream.replace_all ".booking-#{@payment.booking_id}-payment-status", + @payment.booking.payment_status -= turbo_stream.replace "booking-#{@payment.booking_id}-payments-sum", - @payment.booking.payments_total \ No newline at end of file + = turbo_stream.replace "booking-#{@payment.booking_id}-payments-sum", + @payment.booking.payments_total + +- if @payment.stay + = turbo_stream.replace @payment, + partial: "payments/stay_payments", + locals: { payment: @payment } + + = turbo_stream.replace_all ".stay-#{@payment.stay_id}-payment-status", + @payment.stay.payment_status + + = turbo_stream.replace "stay-#{@payment.stay_id}-payments-sum", + @payment.stay.payments_total diff --git a/app/views/public/stays/_calendar_week.html.slim b/app/views/public/stays/_calendar_week.html.slim new file mode 100644 index 0000000..e983fa2 --- /dev/null +++ b/app/views/public/stays/_calendar_week.html.slim @@ -0,0 +1,44 @@ +/! +/ Partial : calendrier hebdomadaire du séjour (lundi-dimanche, multi-semaines) +- require 'date' +- start_date = @stay.start_date +- end_date = @stay.end_date + +- # Calcul du lundi de la première semaine à afficher +- first_monday = start_date - ((start_date.wday + 6) % 7) +- # Calcul du dimanche de la dernière semaine à afficher +- last_sunday = end_date + (7 - end_date.wday) % 7 + +- # Générer toutes les semaines à afficher (tableau de 7 jours, du lundi au dimanche) +- weeks = [] +- d = first_monday +- while d <= last_sunday + - week = (0..6).map { |i| d + i } + - weeks << week + - d += 7 + +- # Indexer les stay_items par date +- stay_items_by_day = Hash.new { |h, k| h[k] = [] } +- stay_items.each do |item| + - (item.object.start_date..item.object.end_date).each do |d| + - stay_items_by_day[d] << item + +.space-y-4 + - weeks.each do |week| + table.table-auto.w-full.text-sm + thead + tr + - week.each do |day| + th.px-2.py-2.text-gray-500.bg-gray-50 + span.font-normal = I18n.t('date.day_names')[day.wday].capitalize + br + span.font-semibold = day.strftime('%d/%m') + tbody + tr + - week.each do |day| + td.px-2.py-2.align-top.bg-white.border.border-gray-200.rounded + - if stay_items_by_day[day].any? + - stay_items_by_day[day].each do |item| + .mb-1.p-1.rounded.bg-teal-50.shadow-sm + .text-xs.text-gray-500 = item.item_type_label + span.font-medium = item.item_name diff --git a/app/views/public/stays/_status_callout.html.slim b/app/views/public/stays/_status_callout.html.slim new file mode 100644 index 0000000..9aae46a --- /dev/null +++ b/app/views/public/stays/_status_callout.html.slim @@ -0,0 +1,12 @@ +- if stay.pending? + .callout-warning(role="alert") + strong Votre demande de réservation est en attente de confirmation par notre équipe. +- elsif stay.canceled? + .callout-error(role="alert") + strong Votre réservation est annulée. +- elsif stay.declined? + .callout-error(role="alert") + strong Votre réservation n'a pas pu être confirmée. +- elsif stay.confirmed? + .callout-success(role="alert") + strong ✅ Votre réservation est confirmée. diff --git a/app/views/public/stays/_stay_items.html.slim b/app/views/public/stays/_stay_items.html.slim new file mode 100644 index 0000000..8f51a01 --- /dev/null +++ b/app/views/public/stays/_stay_items.html.slim @@ -0,0 +1,70 @@ +/! +/ Partial : liste détaillée des StayItems, affichage continu, trié chronologiquement +- stay_items.sort_by(&:start_date).each do |item| + .bg-white.rounded-lg.shadow-md.p-4.mb-4 + .flex.flex-row.items-center.justify-between.mb-2 + h3.text-base.font-semibold.text-gray-900 = item.item_name + span.text-xs.text-gray-500 = "(#{item.item_type_label})" + - case item.item_type + - when StayItem::LODGING + p.text-gray-700.mb-1 = item.item.description + p.text-gray-600.mb-1 + | Période : + span.font-semibold.ml-1 = item.start_date + | au + span.font-semibold.ml-1 = item.end_date + p.text-gray-600.mb-1 + | Adultes : + span.font-semibold.ml-1 = item.adults + | , Enfants : + span.font-semibold.ml-1 = item.children + | , Bébés : + span.font-semibold.ml-1 = item.babies + - when StayItem::ROOM + p.text-gray-700.mb-1 = item.item.description + p.text-gray-600.mb-1 + | Période : + span.font-semibold.ml-1 = item.start_date + | au + span.font-semibold.ml-1 = item.end_date + p.text-gray-600.mb-1 + | Adultes : + span.font-semibold.ml-1 = item.adults + | , Enfants : + span.font-semibold.ml-1 = item.children + | , Bébés : + span.font-semibold.ml-1 = item.babies + - when StayItem::BED + p.text-gray-600.mb-1 + | Période : + span.font-semibold.ml-1 = item.start_date + | au + span.font-semibold.ml-1 = item.end_date + - when StayItem::SPACE + p.text-gray-700.mb-1 = item.item.description + p.text-gray-600.mb-1 + | Date : + span.font-semibold.ml-1 = item.start_date + | (#{item.duration}) + - when StayItem::RENTAL_ITEM + p.text-gray-600.mb-1 + | Quantité : + span.font-semibold.ml-1 = item.quantity + p.text-gray-600.mb-1 + | Période : + span.font-semibold.ml-1 = item.start_date + | au + span.font-semibold.ml-1 = item.end_date + - when StayItem::EXPERIENCE + p.text-gray-600.mb-1 + | Date : + span.font-semibold.ml-1 = item.start_date + p.text-gray-600.mb-1 + | Adultes : + span.font-semibold.ml-1 = item.adults + | , Enfants : + span.font-semibold.ml-1 = item.children + p.text-gray-600.mb-1 + | Durée : + span.font-semibold.ml-1 = item.duration + | heure(s) diff --git a/app/views/public/stays/_timeline.html.slim b/app/views/public/stays/_timeline.html.slim new file mode 100644 index 0000000..42bcd81 --- /dev/null +++ b/app/views/public/stays/_timeline.html.slim @@ -0,0 +1,21 @@ +/! +/ Partial : frise chronologique du séjour +- require 'date' +- start_date = @stay.start_date +- end_date = @stay.end_date +- days = (start_date..end_date).to_a +- stay_items_by_day = Hash.new { |h, k| h[k] = [] } +- stay_items.each do |item| + - (item.object.start_date..item.object.end_date).each do |d| + - stay_items_by_day[d] << item + +.overflow-x-auto + .flex.flex-row.items-end.gap-4.pb-2 + - days.each do |day| + .flex.flex-col.items-center.min-w-[90px] + .text-xs.text-gray-500.mb-1 = l(day, format: :short) + .w-2.h-2.bg-teal-400.rounded-full.mb-2 + - stay_items_by_day[day].each do |item| + .mb-1.px-2.py-1.bg-teal-50.rounded.shadow.text-xs.text-gray-900.text-center + span.font-medium = item.item_name + span.text-xs.text-gray-500.ml-1 = "(#{item.item_type_label})" \ No newline at end of file diff --git a/app/views/public/stays/show.html.slim b/app/views/public/stays/show.html.slim new file mode 100644 index 0000000..65a9117 --- /dev/null +++ b/app/views/public/stays/show.html.slim @@ -0,0 +1,94 @@ +- content_for :meta_title, "Votre réservation aux 4 Sources" + +- content_for :page_banner do + .text-sm.leading-6.text-white.text-center + | <strong class="font-semibold">Une question </strong>au sujet de votre réservation? + <a href="https://les4sources.notion.site/FAQ-des-h-bergements-aux-4-Sources-7802454de0bb4305845ed32d852e6cf3" target="_blank" class="flex-none rounded-full bg-gray-900 ml-2 py-1 px-3.5 text-sm font-semibold text-white shadow-sm hover:bg-gray-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-900">Consultez la FAQ <span aria-hidden="true">→</span></a> + += render "public/stays/status_callout", + stay: @stay + +.container.mx-auto.max-w-4xl.px-4.space-y-8 + div + h1.text-2xl.font-bold.mb-2.text-gray-900 + | Votre réservation aux 4 Sources + br + span.text-gray-900.font-semibold = @stay.date_range + + .flex.flex-col.md:flex-row.md:items-center.md:justify-between.gap-2.mb-2 + .text-gray-700 + | 📞 Avant/après séjour + br + span.font-semibold +32 490 46 77 10 + .text-gray-700 + | 📞 Pendant le séjour + br + span.font-semibold +32 455 13 61 42 + + div + // Calendrier hebdomadaire + h2.text-lg.font-semibold.mb-4.text-gray-900 + | Calendrier de votre séjour + = render 'public/stays/calendar_week', stay: @stay, stay_items: @stay_items + + / div + // Frise chronologique + h2.text-lg.font-semibold.mb-4.text-gray-900 + | Frise chronologique de votre séjour + = render 'public/stays/timeline', stay: @stay, stay_items: @stay_items + + / div + // Liste des StayItems (affichage continu, trié chronologiquement) + h2.text-lg.font-semibold.mb-4.text-gray-900 + | Détail de votre séjour + = render 'public/stays/stay_items', stay_items: @stay_items + + - if @ordered_products.present? + div + h2.text-lg.font-semibold.mb-4.text-gray-900 + | Produits commandés + table.min-w-full.divide-y.divide-gray-200 + thead + tr + th.text-left.px-4.py-2.text-gray-700.font-medium Nom du produit + th.text-left.px-4.py-2.text-gray-700.font-medium Quantité + tbody.bg-white.divide-y.divide-gray-100 + - @ordered_products.each do |product| + tr + td.px-4.py-2.text-gray-900 = product.item.name + td.px-4.py-2.text-gray-900 = product.quantity + + - if @stay.public_notes.present? && !strip_tags(@stay.public_notes).strip.empty? + div + h2.text-lg.font-semibold.mb-4.text-gray-900 + | Notes concernant votre réservation + .text-base.text-gray-800 + == @stay.public_notes + + // Récapitulatif du prix + div + h2.text-lg.font-semibold.mb-4.text-gray-900 + | Récapitulatif du prix du séjour + table.min-w-full.divide-y.divide-gray-200.mb-4 + thead + tr + th.text-left.px-4.py-2.text-gray-700.font-medium   + th.text-left.px-4.py-2.text-gray-700.font-medium Élément + th.text-left.px-4.py-2.text-gray-700.font-medium Montant (€) + tbody.bg-white.divide-y.divide-gray-100 + - total_items = 0 + - @stay_items.each do |item| + tr + td.px-4.py-2.text-gray-900 = item.item_type_label + td.px-4.py-2.text-gray-900 = item.item_name + td.px-4.py-2.text-gray-900 = number_to_currency(item.calculated_price_cents / 100.0, unit: "€", format: "%n %u") + - total_items += item.calculated_price_cents + - if @stay.final_price_cents < total_items + tr + td   + td.px-4.py-2.text-gray-900.font-semibold Réduction octroyée + td.px-4.py-2.text-gray-900.font-semibold.text-red-600 = "-#{number_to_currency((total_items - @stay.final_price_cents) / 100.0, unit: "€", format: "%n %u")}" + tr + td   + td.px-4.py-2.text-gray-900.font-bold Total à payer + td.px-4.py-2.text-gray-900.font-bold = number_to_currency(@stay.final_price_cents / 100.0, unit: "€", format: "%n %u") \ No newline at end of file diff --git a/app/views/public_activity/stay/_create.html.slim b/app/views/public_activity/stay/_create.html.slim new file mode 100644 index 0000000..9a0d384 --- /dev/null +++ b/app/views/public_activity/stay/_create.html.slim @@ -0,0 +1,38 @@ +- stay = activity.trackable.decorate rescue nil + +- if stay + li + .relative.pb-8 + span.absolute.left-4.top-4.-ml-px.h-full.w-0.5.bg-gray-200[aria-hidden="true"] + .relative.flex.space-x-6 + div + span.h-8.w-8.rounded-full.bg-gray-400.flex.items-center.justify-center.ring-8.ring-white + svg.h-5.w-5.text-white[viewbox="0 0 20 20" fill="currentColor" aria-hidden="true"] + path[d="M10 8a3 3 0 100-6 3 3 0 000 6zM3.465 14.493a1.23 1.23 0 00.41 1.412A9.957 9.957 0 0010 18c2.31 0 4.438-.784 6.131-2.1.43-.333.604-.903.408-1.41a7.002 7.002 0 00-13.074.003z"] + .flex.min-w-0.flex-1.justify-between.space-x-4.pt-1.5 + div + p.text-sm.text-gray-900 + - if stay.from_web? + => link_to stay.group_or_name, + stay_path(stay), + class: "font-medium text-blue-700 hover:text-blue-900 focus:text-blue-900" + | a envoyé une demande de séjour depuis le formulaire en ligne + - else + | Nouveau séjour pour + =< link_to stay.group_or_name, + stay_path(stay), + class: "font-medium text-blue-700 hover:text-blue-900 focus:text-blue-900" + + .md:flex.mt-2 + .mr-2.text-sm.text-gray-500 + = stay.date_range + - if !stay.lodgings.empty? + p.mb-2.text-sm + = stay.lodging_badge + - elsif !stay.rooms.empty? + .grid.grid-cols-3.gap-1.mb-2 + = stay.rooms_badges + + .whitespace-nowrap.text-right.text-sm.text-gray-500 + time[datetime="#{activity.created_at.iso8601}"] + = l(activity.created_at, format: :short) diff --git a/app/views/public_activity/stay/_destroy.html.slim b/app/views/public_activity/stay/_destroy.html.slim new file mode 100644 index 0000000..6884048 --- /dev/null +++ b/app/views/public_activity/stay/_destroy.html.slim @@ -0,0 +1,33 @@ +- stay = Stay.unscoped.where(id: activity.trackable_id)&.first&.decorate + +- if stay + li + .relative.pb-8 + span.absolute.left-4.top-4.-ml-px.h-full.w-0.5.bg-gray-200[aria-hidden="true"] + .relative.flex.space-x-6 + div + span.h-8.w-8.rounded-full.bg-gray-400.flex.items-center.justify-center.ring-8.ring-white + svg.h-5.w-5.text-white[viewbox="0 0 20 20" fill="currentColor" aria-hidden="true"] + path[d="M10 8a3 3 0 100-6 3 3 0 000 6zM3.465 14.493a1.23 1.23 0 00.41 1.412A9.957 9.957 0 0010 18c2.31 0 4.438-.784 6.131-2.1.43-.333.604-.903.408-1.41a7.002 7.002 0 00-13.074.003z"] + .flex.min-w-0.flex-1.justify-between.space-x-4.pt-1.5 + div + p.text-sm.text-gray-900 + | Le séjour de + =<> link_to stay.group_or_name, + stay_path(stay), + class: "font-medium text-blue-700 hover:text-blue-900 focus:text-blue-900" + | a été supprimé + + .md:flex.mt-2 + .mr-2.text-sm.text-gray-500 + = stay.date_range + - if !stay.lodging.nil? + p.mb-2.text-sm + = stay.lodging_badge + - else + .flex.space-x-1 + = stay.rooms_badges + + .whitespace-nowrap.text-right.text-sm.text-gray-500 + time[datetime="#{activity.created_at.iso8601}"] + = l(activity.created_at, format: :short) diff --git a/app/views/public_activity/stay/_update.html.slim b/app/views/public_activity/stay/_update.html.slim new file mode 100644 index 0000000..c880416 --- /dev/null +++ b/app/views/public_activity/stay/_update.html.slim @@ -0,0 +1,33 @@ +- stay = activity.trackable.decorate rescue nil + +- if stay + li + .relative.pb-8 + span.absolute.left-4.top-4.-ml-px.h-full.w-0.5.bg-gray-200[aria-hidden="true"] + .relative.flex.space-x-6 + div + span.h-8.w-8.rounded-full.bg-gray-400.flex.items-center.justify-center.ring-8.ring-white + svg.h-5.w-5.text-white[viewbox="0 0 20 20" fill="currentColor" aria-hidden="true"] + path[d="M10 8a3 3 0 100-6 3 3 0 000 6zM3.465 14.493a1.23 1.23 0 00.41 1.412A9.957 9.957 0 0010 18c2.31 0 4.438-.784 6.131-2.1.43-.333.604-.903.408-1.41a7.002 7.002 0 00-13.074.003z"] + .flex.min-w-0.flex-1.justify-between.space-x-4.pt-1.5 + div + p.text-sm.text-gray-900 + | Le séjour de + =<> link_to stay.group_or_name, + stay_path(stay), + class: "font-medium text-blue-700 hover:text-blue-900 focus:text-blue-900" + | a été mis à jour + + .md:flex.mt-2 + .mr-2.text-sm.text-gray-500 + = stay.date_range + - if !stay.lodgings.empty? + p.mb-2.text-sm + = stay.lodging_badge + - elsif !stay.rooms.empty? + .grid.grid-cols-3.gap-1.mb-2 + = stay.rooms_badges + + .whitespace-nowrap.text-right.text-sm.text-gray-500 + time[datetime="#{activity.created_at.iso8601}"] + = l(activity.created_at, format: :short) diff --git a/app/views/reports/lodging.html.slim b/app/views/reports/lodging.html.slim index 2c61941..3905875 100644 --- a/app/views/reports/lodging.html.slim +++ b/app/views/reports/lodging.html.slim @@ -17,7 +17,7 @@ h2.mt-6.text-lg.font-bold Occupation et revenus th.px-3.py-3.5.w-48.text-left.text-sm.font-semibold.text-gray-900[scope="col"] |   th.px-3.py-3.5.w-48.text-left.text-sm.font-semibold.text-gray-900[scope="col"] - | Réservations + | Nuitées th.px-3.py-3.5.w-48.text-left.text-sm.font-semibold.text-gray-900[scope="col"] | Hébergés th.px-3.py-3.5.w-48.text-left.text-sm.font-semibold.text-gray-900[scope="col" colspan="2"] @@ -57,7 +57,7 @@ h2.mt-6.text-lg.font-bold Occupation et revenus .inline-flex = @lodging.monthly_reports_bar(Date.new(@year, month, 1)) td.whitespace-nowrap.px-3.py-4.text-sm.text-gray-700 - = @lodging.count_bookings(Date.new(@year, month, 1), Date.new(@year, month, 1).end_of_month) + = @lodging.count_stays(Date.new(@year, month, 1), Date.new(@year, month, 1).end_of_month) td.whitespace-nowrap.px-3.py-4.text-sm.text-gray-700 = @lodging.count_people(Date.new(@year, month, 1), Date.new(@year, month, 1).end_of_month) td.whitespace-nowrap.px-3.py-4.text-sm.text-gray-700 @@ -81,7 +81,7 @@ h2.mt-6.text-lg.font-bold Occupation et revenus td   td   td.font-medium.whitespace-nowrap.px-3.py-4.text-sm.text-gray-900 - = @lodging.count_bookings(Date.new(@year, 1, 1), Date.new(@year, 12, 31)) + = @lodging.count_stays(Date.new(@year, 1, 1), Date.new(@year, 12, 31)) td.font-medium.whitespace-nowrap.px-3.py-4.text-sm.text-gray-900 = @lodging.count_people(Date.new(@year, 1, 1), Date.new(@year, 12, 31)) td.font-medium.whitespace-nowrap.px-3.py-4.text-sm.text-gray-900 diff --git a/app/views/simple_calendar/_dashboard_calendar.html.erb b/app/views/simple_calendar/_dashboard_calendar.html.erb index 025865a..fff064d 100644 --- a/app/views/simple_calendar/_dashboard_calendar.html.erb +++ b/app/views/simple_calendar/_dashboard_calendar.html.erb @@ -1,8 +1,6 @@ <% if @date.month == Date.today.month && @date.year == Date.today.year %> <!--div class="mb-4 p-4 pb-1 bg-white border border-gray-500 shadow-xl rounded-lg"--> - <div class="relative isolate overflow-hidden bg-emerald-700 mb-4 px-4 pt-4 py-2 text-white shadow-lg rounded-lg"> - <h2 class="font-medium text-lg text-emerald-100">👋 Aujourd'hui aux 4 Sources</h2> - + <div class="mb-8"> <div class="flex items-center -space-x-6"> <% watchmen = HumanRole.where(date: Date.today, role_id: 1) %> <% if watchmen.any? %> @@ -49,10 +47,16 @@ </div> <% end %> <% end %> - </div> - <div class="absolute -top-24 right-0 -z-10 transform-gpu blur-3xl" aria-hidden="true"> - <div class="aspect-[1404/767] w-[87.75rem] bg-gradient-to-br from-emerald-200 to-cyan-400 opacity-25" style="clip-path: polygon(73.6% 51.7%, 91.7% 11.8%, 100% 46.4%, 97.4% 82.2%, 92.5% 84.9%, 75.7% 64%, 55.3% 47.5%, 46.5% 49.4%, 45% 62.9%, 50.3% 87.2%, 21.3% 64.1%, 0.1% 100%, 5.4% 51.1%, 21.4% 63.9%, 58.9% 0.2%, 73.6% 51.7%)"></div> + <% if !@grouped_stay_reservations[Date.today].nil? %> + <% @grouped_stay_reservations[Date.today].sort_by {|r| r.start_time }.group_by { |r| r.stay.id }.each do |grouped_reservations| %> + <% reservation = grouped_reservations.last.first %> + <% stay = StayDecorator.new(reservation.stay) %> + <div class="place-self-stretch text-sm font-light text-gray-500 bg-white border border-gray-200 rounded-lg shadow-xl"> + <%= render "stays/popover", stay: stay %> + </div> + <% end %> + <% end %> </div> </div> <% end %> diff --git a/app/views/stay_items/_form.html.slim b/app/views/stay_items/_form.html.slim new file mode 100644 index 0000000..e09da40 --- /dev/null +++ b/app/views/stay_items/_form.html.slim @@ -0,0 +1,5 @@ += f.hidden_field :item_type, + value: f.object.item_type, + data: { "stay-items-target": "itemType" } + += render "stay_items/forms/#{f.object.item_type.downcase}", f: f diff --git a/app/views/stay_items/_stay_item.html.slim b/app/views/stay_items/_stay_item.html.slim new file mode 100644 index 0000000..7d92f29 --- /dev/null +++ b/app/views/stay_items/_stay_item.html.slim @@ -0,0 +1,11 @@ +tr(id="#{dom_id stay_item}") + td.whitespace-nowrap.py-4.pr-4.text-right + = item_badge(stay_item.item_type, stay_item.item_type_label) + td.whitespace-nowrap.py-4.pl-4.pr-3.text-sm.font-medium.text-gray-900.sm:pl-0 + = link_to stay_item.item_name, edit_stay_stay_item_path(stay_id: stay_item.stay_id, id: stay_item.id), + { data: { turbo_frame: "modal" }, + class: 'text-blue-500 border-b-2 border-blue-200 hover:text-blue-700 focus:text-blue-700'} + .mt-1.text-gray-900 + = stay_item.item_info + td.whitespace-nowrap.px-3.py-4.text-md.text-right + = stay_item.calculated_price.format(symbol: true) \ No newline at end of file diff --git a/app/views/stay_items/edit.html.slim b/app/views/stay_items/edit.html.slim new file mode 100644 index 0000000..5dca29c --- /dev/null +++ b/app/views/stay_items/edit.html.slim @@ -0,0 +1,18 @@ += render TurboModal::Component.new(title: @modal_title, width: :md) do + = form_with model: [@stay, @stay_item], + data: { controller: "stay-items" } do |f| + .space-y-8.divide-y.divide-gray-200.sm:space-y-5 + .space-y-6.sm:space-y-5 + = render "form", f: f + + + = f.actions do + = link_to "Retirer l'élément du séjour", + stay_stay_item_path(@stay_item.stay, @stay_item), + method: :delete, + class: "text-xs font-semibold text-red-500", + data: { \ + turbo_method: :delete, + action: "turbo-modal--component#closeDialog" \ + } + = f.submit "Enregistrer" \ No newline at end of file diff --git a/app/views/stay_items/forms/_bed.html.slim b/app/views/stay_items/forms/_bed.html.slim new file mode 100644 index 0000000..30b5909 --- /dev/null +++ b/app/views/stay_items/forms/_bed.html.slim @@ -0,0 +1,35 @@ +.space-y-2 + = f.label :item_id, "Sélectionne un lit *" + = f.collection_radio_buttons :item_id, Bed.all.order(:id), :id, :form_label, required: true do |radio_button| + .flex.items-center + = radio_button.radio_button data: { "stay-items-target": "bedSelection" } + = radio_button.label(class: "ml-3 block text-sm font-medium text-gray-700") + +.space-y-6.md:space-y-0.md:flex.md:space-x-6 + = f.date_field :start_date, + html5: true, + label: "Du *", + required: true, + error: "Une date de début est requise", + data: {"stay-items-target": "bedStartDate" } + + = f.date_field :end_date, + html5: true, + label: "Au *", + required: true, + error: "Une date de fin est requise", + data: {"stay-items-target": "bedEndDate" } + +.flex.space-x-6 + = f.number_field :calculated_price, + label: "Tarif *", + min: 0, + required: true, + class: "w-24 text-lg", + step: "0.01", + value: ((f.object.calculated_price_cents / 100.0) rescue nil), + data: { "stay-items-target": "bedPrice" } + + = link_to "Obtenir le tarif", '#', + data: { action: "stay-items#clickBedPrice"}, + class: "btn-page-header-no-background h-10 py-2 mt-6" diff --git a/app/views/stay_items/forms/_experience.html.slim b/app/views/stay_items/forms/_experience.html.slim new file mode 100644 index 0000000..0f0e04b --- /dev/null +++ b/app/views/stay_items/forms/_experience.html.slim @@ -0,0 +1,53 @@ +.space-y-2 + = f.label :item_id, "Sélectionne un atelier *" + = f.collection_radio_buttons :item_id, Experience.all.order(:id), :id, :name, required: true do |radio_button| + .flex.items-center + = radio_button.radio_button data: { "stay-items-target": "experienceSelection" } + = radio_button.label(class: "ml-3 block text-sm font-medium text-gray-700") + +.space-y-6.md:space-y-0.md:flex.md:space-x-6 + = f.date_field :start_date, + html5: true, + label: "Date *", + required: true, + error: "Veuillez renseigner une date", + data: { "stay-items-target": "experienceStartDate" } + +.flex.space-x-6 + = f.number_field :adults_count, + label: "Nombre d'adultes *", + min: 0, + remove_default_class: "sm:text-sm", + class: "w-24 text-lg", + data: { "stay-items-target": "experienceAdultCount" } + + = f.number_field :children_count, + label: "Nombre d'enfants *", + min: 0, + required: true, + remove_default_class: "sm:text-sm", + class: "w-24 text-lg", + data: { "stay-items-target": "experienceChildrenCount" } + +.col-span-2.mb-4.space-y-6.sm:space-y-5 + = f.text_field :duration, + label: "Durée de l'atelier *", + required: true, + class: "md:w-1/2 lg:w-2/3", + data: { "stay-items-target": "experienceDuration" } + +.flex.space-x-6 + = f.number_field :calculated_price, + label: "Tarif *", + min: 0, + required: true, + class: "w-24 text-lg", + step: "0.01", + value: ((f.object.calculated_price_cents / 100.0) rescue nil), + data: { "stay-items-target": "experiencePrice" } + + = link_to "Obtenir le tarif", '#', + data: { action: "stay-items#clickExperiencePrice"}, + class: "btn-page-header-no-background h-10 py-2 mt-6" + + diff --git a/app/views/stay_items/forms/_lodging.html.slim b/app/views/stay_items/forms/_lodging.html.slim new file mode 100644 index 0000000..5116421 --- /dev/null +++ b/app/views/stay_items/forms/_lodging.html.slim @@ -0,0 +1,53 @@ +.space-y-2 + = f.label :item_id, "Sélectionne un hébergement *" + = f.collection_radio_buttons :item_id, Lodging.all.order(:id), :id, :form_label, required: true do |radio_button| + .flex.items-center + = radio_button.radio_button data: { "stay-items-target": "lodgingSelection" } + = radio_button.label(class: "ml-3 block text-sm font-medium text-gray-700") + +.space-y-6.md:space-y-0.md:flex.md:space-x-6 + = f.date_field :start_date, + html5: true, + label: "Du *", + required: true, + error: "Une date de début est requise", + data: {"stay-items-target": "lodgingStartDate" } + + = f.date_field :end_date, + html5: true, + label: "Au *", + required: true, + error: "Une date de fin est requise", + data: {"stay-items-target": "lodgingEndDate" } + +.flex.space-x-6 + = f.number_field :adults_count, + label: "Nombre d'adultes", + min: 0, + remove_default_class: "sm:text-sm", + class: "w-24 text-lg" + + = f.number_field :children_count, + label: "Nombre d'enfants", + min: 0, + remove_default_class: "sm:text-sm", + class: "w-24 text-lg" + + = f.number_field :babies_count, + label: "Nombre de bébés", + min: 0, + remove_default_class: "sm:text-sm", + class: "w-24 text-lg" +.flex.space-x-6 + = f.number_field :calculated_price, + label: "Tarif *", + min: 0, + required: true, + class: "w-24 text-lg", + step: "0.01", + value: ((f.object.calculated_price_cents / 100.0) rescue nil), + data: { "stay-items-target": "lodgingPrice" } + + = link_to "Obtenir le tarif", '#', + data: { action: "stay-items#clickLodgingPrice"}, + class: "btn-page-header-no-background h-10 py-2 mt-6" diff --git a/app/views/stay_items/forms/_product.html.slim b/app/views/stay_items/forms/_product.html.slim new file mode 100644 index 0000000..4661412 --- /dev/null +++ b/app/views/stay_items/forms/_product.html.slim @@ -0,0 +1,27 @@ +.space-y-2 + = f.label :item_id, "Sélectionne un produit *" + = f.collection_radio_buttons :item_id, Product.all.order(:id), :id, :name, required: true do |radio_button| + .flex.items-center + = radio_button.radio_button data: { "stay-items-target": "productSelection" } + = radio_button.label(class: "ml-3 block text-sm font-medium text-gray-700") + += f.number_field :quantity, + label: "Quantité", + min: 1, + class: "w-24 text-lg", + data: { "stay-items-target": "productQuantity" } + +.flex.space-x-6 + = f.number_field :calculated_price, + label: "Tarif *", + min: 0, + required: true, + class: "w-24 text-lg", + step: "0.01", + value: ((f.object.calculated_price_cents / 100.0) rescue nil), + data: { "stay-items-target": "productPrice" } + + = link_to "Obtenir le tarif", '#', + data: { action: "stay-items#clickProductPrice"}, + class: "btn-page-header-no-background h-10 py-2 mt-6" + diff --git a/app/views/stay_items/forms/_rentalitem.html.slim b/app/views/stay_items/forms/_rentalitem.html.slim new file mode 100644 index 0000000..2e7d293 --- /dev/null +++ b/app/views/stay_items/forms/_rentalitem.html.slim @@ -0,0 +1,41 @@ +.space-y-2 + = f.label :item_id, "Sélectionne un élément *" + = f.collection_radio_buttons :item_id, RentalItem.all.order(:id), :id, :name, required: true do |radio_button| + .flex.items-center + = radio_button.radio_button data: { "stay-items-target": "rentalItemSelection" } + = radio_button.label(class: "ml-3 block text-sm font-medium text-gray-700") + += f.number_field :quantity, + label: "Quantité", + min: 1, + remove_default_class: "sm:text-sm", + class: "w-24 text-lg", + data: { "stay-items-target": "rentalItemQuantity" } + +.space-y-6.md:space-y-0.md:flex.md:space-x-6 + = f.date_field :start_date, + html5: true, + label: "Du *", + required: true, + error: "Veuillez renseigner une date", + data: { "stay-items-target": "rentalItemStartDate" } + = f.date_field :end_date, + html5: true, + label: "Au *", + required: true, + error: "Veuillez renseigner une date", + data: { "stay-items-target": "rentalItemEndDate" } + +.flex.space-x-6 + = f.number_field :calculated_price, + label: "Tarif *", + min: 0, + required: true, + class: "w-24 text-lg", + step: "0.01", + value: ((f.object.calculated_price_cents / 100.0) rescue nil), + data: { "stay-items-target": "rentalItemPrice" } + + = link_to "Obtenir le tarif", '#', + data: { action: "stay-items#clickRentalItemPrice"}, + class: "btn-page-header-no-background h-10 py-2 mt-6" \ No newline at end of file diff --git a/app/views/stay_items/forms/_room.html.slim b/app/views/stay_items/forms/_room.html.slim new file mode 100644 index 0000000..d6203b8 --- /dev/null +++ b/app/views/stay_items/forms/_room.html.slim @@ -0,0 +1,55 @@ +.space-y-2 + = f.label :item_id, "Sélectionne une chambre *" + = f.collection_radio_buttons :item_id, Room.all.order(:id), :id, :form_label, required: true do |radio_button| + .flex.items-center + = radio_button.radio_button data: { "stay-items-target": "roomSelection" } + = radio_button.label(class: "ml-3 block text-sm font-medium text-gray-700") + +.space-y-6.md:space-y-0.md:flex.md:space-x-6 + = f.date_field :start_date, + html5: true, + label: "Du *", + required: true, + error: "Une date de début est requise", + data: {"stay-items-target": "roomStartDate" } + + = f.date_field :end_date, + html5: true, + label: "Au *", + required: true, + error: "Une date de fin est requise", + data: {"stay-items-target": "roomEndDate" } + +.flex.space-x-6 + = f.number_field :adults_count, + label: "Nombre d'adultes *", + min: 0, + remove_default_class: "sm:text-sm", + class: "w-24 text-lg" + + = f.number_field :children_count, + label: "Nombre d'enfants *", + min: 0, + required: true, + remove_default_class: "sm:text-sm", + class: "w-24 text-lg" + + = f.number_field :babies_count, + label: "Nombre de bébés", + min: 0, + remove_default_class: "sm:text-sm", + class: "w-24 text-lg" + +.flex.space-x-6 + = f.number_field :calculated_price, + label: "Tarif *", + min: 0, + required: true, + class: "w-24 text-lg", + step: "0.01", + value: ((f.object.calculated_price_cents / 100.0) rescue nil), + data: { "stay-items-target": "roomPrice" } + + = link_to "Obtenir le tarif", '#', + data: { action: "stay-items#clickRoomPrice"}, + class: "btn-page-header-no-background h-10 py-2 mt-6" diff --git a/app/views/stay_items/forms/_space.html.slim b/app/views/stay_items/forms/_space.html.slim new file mode 100644 index 0000000..fa9a2f3 --- /dev/null +++ b/app/views/stay_items/forms/_space.html.slim @@ -0,0 +1,45 @@ +.space-y-2 + = f.label :item_id, "Sélectionne un espace *" + = f.collection_radio_buttons :item_id, SpaceDecorator.decorate_collection(Space.all), :id, :name_and_description do |radio_button| + .flex.items-center + = radio_button.radio_button data: { "stay-items-target": "spaceSelection" } + = radio_button.label(class: "ml-3 block text-sm font-medium text-gray-700") + +.space-y-6.md:space-y-0.md:flex.md:space-x-6 + = f.date_field :start_date, + html5: true, + label: "Date *", + required: true, + error: "Une date est requise", + data: { "stay-items-target": "spaceStartDate" } + +.space-y-2 + = f.label :duration, + "Période *" + = f.collection_radio_buttons :duration, + [ \ + ["2 heures (préciser la période dans les notes)", "2h"], + ["Journée", "day"], + ["Soirée", "evening"], + ["Journée + soirée", "fullday"], + ["Voir notes", "see_notes"] \ + ], + :last, :first, + required: true do |radio_button| + .flex.items-center + = radio_button.radio_button data: { "stay-items-target": "spaceDuration" } + = radio_button.label(class: "ml-3 block text-sm font-medium text-gray-700") + +.flex.space-x-6 + = f.number_field :calculated_price, + label: "Tarif *", + min: 0, + required: true, + class: "w-24 text-lg", + step: "0.01", + value: ((f.object.calculated_price_cents / 100.0) rescue nil), + data: { "stay-items-target": "spacePrice" } + + = link_to "Obtenir le tarif", '#', + data: { action: "stay-items#clickSpacePrice"}, + class: "btn-page-header-no-background h-10 py-2 mt-6" \ No newline at end of file diff --git a/app/views/stay_items/new.html.slim b/app/views/stay_items/new.html.slim new file mode 100644 index 0000000..e851665 --- /dev/null +++ b/app/views/stay_items/new.html.slim @@ -0,0 +1,10 @@ += render TurboModal::Component.new(title: @modal_title, width: :md) do + = form_with model: @stay_item, + url: stay_stay_items_path, + data: { controller: "stay-items" } do |f| + .space-y-8.divide-y.divide-gray-200.sm:space-y-5 + .space-y-6.sm:space-y-5 + = render "form", f: f + + = f.actions do + = f.submit "Ajouter au séjour" diff --git a/app/views/stays/_form.html.slim b/app/views/stays/_form.html.slim new file mode 100644 index 0000000..104401e --- /dev/null +++ b/app/views/stays/_form.html.slim @@ -0,0 +1,11 @@ += f.hidden_field :stay_id, id: 'stay_id', + value: @stay.id, + data: { "stay-target": "stayId" } + += render "stays/form/info", f: f += render "stays/form/stay", f: f += render "stays/form/items", f: f += render "stays/form/payments", f: f += render "stays/form/notes", f: f + +#stays-for-date-range(data-stay-target="staysForDateRange") \ No newline at end of file diff --git a/app/views/stays/_popover.html.slim b/app/views/stays/_popover.html.slim new file mode 100644 index 0000000..1e84da8 --- /dev/null +++ b/app/views/stays/_popover.html.slim @@ -0,0 +1,46 @@ +.p-1.text-center.text-xs.font-bold.bg-cyan-200.text-cyan-700.rounded-t-lg + - if Date.today == stay.object.start_date + | Check-in + - elsif Date.today == stay.object.end_date + | Check-out + - elsif Date.today < stay.object.start_date + | Séjour à venir + - elsif Date.today > stay.object.start_date && Date.today < stay.object.end_date + | Séjour en cours + - else + | Séjour passé +.p-3.space-y-2 + .flex.items-center.justify-between.mb-2 + .text-sm.font-semibold.leading-none.text-gray-900 + = link_to stay.customer_name, + stay_path(stay), + class: "text-blue-700 hover:text-blue-900 focus:text-blue-900" + div + = link_to "Détails", + stay_path(stay), + class: "text-white bg-cyan-700 hover:bg-cyan-800 focus:ring-4 focus:ring-cyan-300 font-medium rounded-lg text-xs px-3 py-1.5 focus:outline-none" + + .space-y-1.mb-1 + = stay.accommodation_tags + = stay.spaces_tags + + p.text-sm.font-medium + = stay.payment_status + + ul.flex.text-base.font-light.mb-2 + - if stay.adults.to_i > 0 + li.mr-2 + span.font-semibold.text-gray-900 = "#{stay.adults} adultes" + span.ml-1 🧑 + - if stay.children.to_i > 0 + li.mr-2 + span.font-semibold.text-gray-900 = "#{stay.children} enfants" + span.ml-1 👧 + - if stay.babies.to_i > 0 + li.mr-2 + span.font-semibold.text-gray-900 = "#{stay.babies} bébés" + span.ml-1 👶 + + - if stay.notes.presence + .mt-2.text-yellow-600.font-caveat.text-lg + = simple_format "📝 " + truncate(stay.notes, length: 150) diff --git a/app/views/stays/_status_callout.html.slim b/app/views/stays/_status_callout.html.slim new file mode 100644 index 0000000..a991850 --- /dev/null +++ b/app/views/stays/_status_callout.html.slim @@ -0,0 +1,12 @@ +- if stay.pending? + .callout-warning(role="alert") + strong Cette réservation est en attente de confirmation. +- elsif stay.canceled? + .callout-error(role="alert") + strong Cette réservation est annulée. +- elsif stay.declined? + .callout-error(role="alert") + strong Cette réservation n'a pas pu être confirmée. +- elsif stay.confirmed? + .callout-success(role="alert") + strong Cette réservation est confirmée. diff --git a/app/views/stays/_stay_items_links.html.slim b/app/views/stays/_stay_items_links.html.slim new file mode 100644 index 0000000..650aad3 --- /dev/null +++ b/app/views/stays/_stay_items_links.html.slim @@ -0,0 +1,28 @@ += link_to "+ Hébergement", + new_stay_stay_item_path(stay, type: StayItem::LODGING), + data: { turbo_frame: "modal" }, + class: "relative inline-flex items-center rounded-l-md #{lodging_color} px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-10" += link_to "+ Chambre", + new_stay_stay_item_path(stay, type: StayItem::ROOM), + data: { turbo_frame: "modal" }, + class: "relative inline-flex items-center rounded-l-md #{room_color} px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-10" += link_to "+ Lit", + new_stay_stay_item_path(stay, type: StayItem::BED), + data: { turbo_frame: "modal" }, + class: "relative inline-flex items-center rounded-l-md #{bed_color} px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-10" += link_to "+ Espace", + new_stay_stay_item_path(stay, type: StayItem::SPACE), + data: { turbo_frame: "modal" }, + class: "relative -ml-px inline-flex items-center #{space_color} px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-10" += link_to "+ Location", + new_stay_stay_item_path(stay, type: StayItem::RENTAL_ITEM), + data: { turbo_frame: "modal" }, + class: "relative -ml-px inline-flex items-center #{rental_item_color} px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-10" += link_to "+ Atelier", + new_stay_stay_item_path(stay, type: StayItem::EXPERIENCE), + data: { turbo_frame: "modal" }, + class: "relative -ml-px inline-flex items-center #{experience_color} px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-10" += link_to "+ Produit", + new_stay_stay_item_path(stay, type: StayItem::PRODUCT), + data: { turbo_frame: "modal" }, + class: "relative -ml-px inline-flex items-center rounded-r-md #{product_color} px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-10" diff --git a/app/views/stays/_table.html.slim b/app/views/stays/_table.html.slim new file mode 100644 index 0000000..8087387 --- /dev/null +++ b/app/views/stays/_table.html.slim @@ -0,0 +1,50 @@ +.overflow-x-auto.relative + table.w-full.text-sm.text-left.text-gray-500 + thead.text-xs.text-gray-700.uppercase.bg-gray-50 + tr + th.py-3.px-3.w-2[scope="col"] + |   + th.py-3.px-6.w-64[scope="col"] + | Nom + th.py-3.px-6.hidden.md:table-cell[scope="col"] + | Nuitées + th.py-3.px-6[scope="col"] + | Hébergement + th.py-3.px-6[scope="col"] + | Paiement + th.py-3.px-6[scope="col"] + | Options + th.py-3.px-6[scope="col"] + | ID + th.py-1.px-1[scope="col"] + | Actions + tbody + - stays.each do |stay| + tr.border-b(class="#{stay.tr_class}") + td.py-4.px-6(class="#{stay.tr_border_class}") + = stay.status_emoji + td.py-4.px-6.font-medium.text-gray-900.whitespace-nowrap[scope="row"] + strong(class="#{stay.declined? ? "line-through" : nil}") + = link_to stay.group_or_name, + stay_path(stay), + class: "text-blue-500 border-b-2 border-blue-200 hover:text-blue-700 focus:text-blue-700" + .mt-1.text-gray-900 + = stay.date_range + td.py-4.px-6.hidden.md:table-cell + = stay.nights_count + td.py-4.stay-6.space-x-1 + - if !stay.lodgings.empty? + = stay.lodging_badge(font_size: "sm") + - if !stay.rooms.empty? + = stay.rooms_badges(font_size: "sm") + td.py-4.px-6 + - if stay.confirmed? + = stay.payment_status + td.py-4.px-6 + -# = stay.options_emojis + td.py-4.px-6 + code #{stay.token} + td.py-1.px-1 + = link_to 'Mettre à jour', + edit_stay_path(stay), + class: "text-blue-500 border-b-2 border-blue-200 hover:text-blue-700 focus:text-blue-700" \ No newline at end of file diff --git a/app/views/stays/_total_amount.html.slim b/app/views/stays/_total_amount.html.slim new file mode 100644 index 0000000..d6cc4c7 --- /dev/null +++ b/app/views/stays/_total_amount.html.slim @@ -0,0 +1,2 @@ + td#total-amount.text-right.py-4 + = number_to_currency(total_amount) \ No newline at end of file diff --git a/app/views/stays/edit.html.slim b/app/views/stays/edit.html.slim new file mode 100644 index 0000000..61b2c50 --- /dev/null +++ b/app/views/stays/edit.html.slim @@ -0,0 +1,13 @@ +- content_for :page_header do + = render 'layouts/components/page_header', + title: "Mettre à jour le séjour" + += form_with model: @stay, + url: stay_path(@stay), + data: { controller: "stay", "stay-id-value": @stay.id } do |f| + .space-y-8.divide-y.divide-gray-200.sm:space-y-5 + .space-y-6.sm:space-y-5 + = render "form", f: f + + = f.actions do + = f.submit "Mettre à jour" diff --git a/app/views/stays/form/_final_price.html.slim b/app/views/stays/form/_final_price.html.slim new file mode 100644 index 0000000..0432dfc --- /dev/null +++ b/app/views/stays/form/_final_price.html.slim @@ -0,0 +1,5 @@ + = number_field_tag :final_price, + required: true, + value: ((stay.total_reservation_amount) rescue nil), + class: "w-1/2 md:w-1/2 lg:w-1/4", + step: "0.01" diff --git a/app/views/stays/form/_info.html.slim b/app/views/stays/form/_info.html.slim new file mode 100644 index 0000000..10d50b6 --- /dev/null +++ b/app/views/stays/form/_info.html.slim @@ -0,0 +1,34 @@ +.border-l-8.border-indigo-700.-ml-4.pl-4.md:p-0.md:m-0.md:border-0.grid.grid-cols-1.md:grid-cols-3.md:gap-4 + .md:border-r-8.md:border-indigo-700.md:pr-4.md:text-right + = section_heading_tw heading: "Informations personnelles" + + .col-span-2.mb-4.space-y-6.sm:space-y-5 + = f.fields_for :customer, @stay.customer do |customer_form| + = customer_form.email_field :email, + label: "Adresse email *", + class: "md:w-2/3 lg:w-1/2", + required: true, + hint: "Le client recevra des notifications par email", + data: { "1p-ignore": true, action: "blur->stay#lookupCustomer", "stay-target": "customerEmail" } + + = customer_form.text_field :firstname, + label: "Prénom *", + class: "md:w-1/2 lg:w-2/3", + data: { "stay-target": "customerFirstname" } + + = customer_form.text_field :lastname, + label: "Nom", + class: "md:w-1/2 lg:w-2/3", + data: { "stay-target": "customerLastname" } + + = customer_form.text_field :phone, + label: "Numéro de téléphone", + class: "md:w-1/2 lg:w-1/3", + data: { "stay-target": "customerPhone" } + + = f.text_field :group_name, + label: "Nom du groupe", + class: "md:w-1/2 lg:w-1/3" + + +hr diff --git a/app/views/stays/form/_items.html.slim b/app/views/stays/form/_items.html.slim new file mode 100644 index 0000000..088b473 --- /dev/null +++ b/app/views/stays/form/_items.html.slim @@ -0,0 +1,72 @@ +.border-l-8.border-indigo-700.-ml-4.pl-4.md:p-0.md:m-0.md:border-0.grid.grid-cols-1.md:grid-cols-3.md:gap-4(data-stay-target="compositionSection") + .md:border-r-8.md:border-indigo-700.md:pr-4.md:text-right + = section_heading_tw heading: "Composition du séjour" + + .col-span-2.mb-4.space-y-6.sm:space-y-5 + / Message affiché quand les dates ne sont pas définies + .dates-required-message.bg-blue-50.border.border-blue-200.rounded-md.p-4.mb-4(data-stay-target="datesRequiredMessage") + .flex.items-center + .flex-shrink-0 + svg.h-5.w-5.text-blue-400(xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor") + path(fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd") + .ml-3 + h3.text-sm.font-medium.text-blue-800 + | Dates de séjour requises + .mt-1.text-sm.text-blue-700 + | Veuillez d'abord sélectionner les dates de début et de fin du séjour pour pouvoir ajouter des éléments à la composition. + + span.isolate.inline-flex.rounded-md.shadow-sm + = link_to "Hébergement", + new_stay_stay_item_path(stay_id: @stay.id, type: StayItem::LODGING), + data: { turbo_frame: "modal" }, + class: "relative inline-flex items-center rounded-l-md #{lodging_color} px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-10" + = link_to "Chambre", + new_stay_stay_item_path(stay_id: @stay.id, type: StayItem::ROOM), + data: { turbo_frame: "modal" }, + class: "relative inline-flex items-center rounded-l-md #{room_color} px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-10" + = link_to "Lit", + new_stay_stay_item_path(stay_id: @stay.id, type: StayItem::BED), + data: { turbo_frame: "modal" }, + class: "relative inline-flex items-center rounded-l-md #{bed_color} px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-10" + = link_to "Espace", + new_stay_stay_item_path(stay_id: @stay.id, type: StayItem::SPACE), + data: { turbo_frame: "modal" }, + class: "relative -ml-px inline-flex items-center #{space_color} px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-10" + = link_to "Location", + new_stay_stay_item_path(stay_id: @stay.id, type: StayItem::RENTAL_ITEM), + data: { turbo_frame: "modal" }, + class: "relative -ml-px inline-flex items-center #{rental_item_color} px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-10" + = link_to "Atelier", + new_stay_stay_item_path(stay_id: @stay.id, type: StayItem::EXPERIENCE), + data: { turbo_frame: "modal" }, + class: "relative -ml-px inline-flex items-center #{experience_color} px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-10" + = link_to "Produit", + new_stay_stay_item_path(stay_id: @stay.id, type: StayItem::PRODUCT), + data: { turbo_frame: "modal" }, + class: "relative -ml-px inline-flex items-center rounded-r-md #{product_color} px-3 py-2 text-sm font-semibold text-gray-900 ring-1 ring-inset ring-gray-300 hover:bg-gray-50 focus:z-10" + + .mt-4.flow-root + .-mx-4.-my-2.overflow-x-auto.sm:-mx-6.lg:-mx-8 + .inline-block.min-w-full.py-2.align-middle.sm:px-6.lg:px-8 + table.min-w-full.divide-y.divide-gray-300 + thead + tr + th.py-3.5.pl-4.pr-3.text-left.text-sm.font-semibold.text-gray-900.sm:pl-0[scope="col"] + | Élément + th.px-3.py-3.5.text-left.text-sm.font-semibold.text-gray-900[scope="col"] + | Price + th.relative.py-3.5.pl-3.pr-4.sm:pr-0[scope="col"] + span.sr-only + | Actions + tbody#stay-items.divide-y.divide-gray-200 + = render partial: "stay_items/stay_item", collection: @stay.stay_items.order(:created_at).decorate, as: :stay_item + tfoot + tr.text-base.font-medium.text-gray-500 + td Montant de la réservation + = render partial: "stays/total_amount", locals: {total_amount: @stay.total_reservation_amount} + +hr + + + + diff --git a/app/views/stays/form/_notes.html.slim b/app/views/stays/form/_notes.html.slim new file mode 100644 index 0000000..64aa0bb --- /dev/null +++ b/app/views/stays/form/_notes.html.slim @@ -0,0 +1,14 @@ +.border-l-8.border-indigo-100.-ml-4.pl-4.md:p-0.md:m-0.md:border-0.grid.grid-cols-1.md:grid-cols-3.md:gap-4 + .md:border-r-8.md:border-indigo-100.md:pr-4.md:text-right + = section_heading_tw heading: "Notes" + + .col-span-2.mb-4.space-y-2 + = f.label :notes, + "Notes éventuelles concernant cette réservation (information interne)" + = f.text_area :notes, + rows: 4 + + .mt-2 + = f.label :public_notes, + "Notes à afficher sur la page web de la réservation (partagées avec le client)" + = f.rich_text_area :public_notes diff --git a/app/views/stays/form/_payments.html.slim b/app/views/stays/form/_payments.html.slim new file mode 100644 index 0000000..a2667bf --- /dev/null +++ b/app/views/stays/form/_payments.html.slim @@ -0,0 +1,35 @@ +.border-l-4.border-slate-400.-ml-4.pl-4.md:p-0.md:m-0.md:border-0.grid.grid-cols-1.md:grid-cols-3.md:gap-4 + .md:border-r-4.md:border-slate-400.md:pr-4.md:text-right + = section_heading_tw heading: "Paiement du séjour" + + .col-span-2.mb-4 + .content + p.text-gray-700 + | 💁‍♀️ #{link_to("Tarifs en vigueur", "https://www.les4sources.be/locations/tarifs", target: "_blank", class: "claudy-link")} sur notre site web + + .mt-4 + = f.number_field :final_price, + label: "Montant convenu à payer", + required: true, + value: ((f.object.final_price_cents / 100) rescue nil), + class: "w-1/2 md:w-1/2 lg:w-1/4", + step: "0.01" + + .mt-4 + = f.label :invoice_status, + "Facture 🧾 *" + + .space-y-3.md:space-y-2 + = f.collection_radio_buttons :invoice_status, + [ \ + ["Non requise", nil], + ["À fournir", "requested"], + ["Envoyée", "sent"] \ + ], + :last, :first, + required: true do |radio_button| + .flex.items-center + = radio_button.radio_button + = radio_button.label(class: "ml-3 block text-sm font-medium text-gray-700") + +hr diff --git a/app/views/stays/form/_stay.html.slim b/app/views/stays/form/_stay.html.slim new file mode 100644 index 0000000..e54e969 --- /dev/null +++ b/app/views/stays/form/_stay.html.slim @@ -0,0 +1,77 @@ +.border-l-8.border-indigo-500.-ml-4.pl-4.md:p-0.md:m-0.md:border-0.grid.grid-cols-1.md:grid-cols-3.md:gap-4 + .md:border-r-8.md:border-indigo-500.md:pr-4.md:text-right + = section_heading_tw heading: "Séjour" + + .col-span-2.mb-4.space-y-6.sm:space-y-5 + .space-y-2 + = f.label :status, + "Statut de la réservation *" + = f.collection_radio_buttons :status, + [["⏳ En attente de confirmation", "pending"], \ + ["✅ Confirmée", "confirmed"], \ + ["🙅‍♀️ Refusée", "declined"], \ + ["❌ Annulée", "canceled"]], + :last, :first, + required: true do |radio_button| + .flex.items-center + = radio_button.radio_button + = radio_button.label(class: "ml-3 block text-sm font-medium text-gray-700") + + + .space-y-2 + = f.label :status, + "Plateforme de réservation *" + = f.collection_radio_buttons :platform, + [["Réservation en direct", "direct"], ["Airbnb", "airbnb"]], + :last, :first, + required: true do |radio_button| + .flex.items-center + = radio_button.radio_button + = radio_button.label(class: "ml-3 block text-sm font-medium text-gray-700") + + .space-y-6.md:space-y-0.md:flex.md:space-x-6 + = f.date_field :start_date, + html5: true, + label: "Du *", + required: true, + error: "Veuillez renseigner une date", + id: "startDate", + data: { "action": "stay#setEndDate change->stay#saveDates stay#drawForm", "stay-target": "startDateInput" } + = f.date_field :end_date, + html5: true, + label: "Au *", + required: true, + error: "Veuillez renseigner une date", + data: {"action": "change->stay#saveDates", "stay-target": "endDateInput" } + + .flex.space-x-6 + = f.number_field :adults, + label: "Nombre d'adultes", + min: 0, + remove_default_class: "sm:text-sm", + class: "w-24 text-lg" + + = f.number_field :children, + label: "Nombre d'enfants", + min: 0, + remove_default_class: "sm:text-sm", + class: "w-24 text-lg" + + = f.number_field :babies, + label: "Nombre de bébés", + min: 0, + remove_default_class: "sm:text-sm", + class: "w-24 text-lg", + hint: "Ne nécessitant pas un lit ou ayant son propre couchage" + + = f.text_field :estimated_arrival, + label: "Heure d'arrivée estimée", + hint: "Laissez vide si le client ne sait pas encore nous préciser son heure d'arrivée", + class: "w-1/2 md:w-1/2 lg:w-1/4" + + = f.text_field :departure_time, + label: "Heure de départ prévue", + hint: "Laissez vide si le client n'a pas encore précisé son heure de départ", + class: "w-1/2 md:w-1/2 lg:w-1/4" + +hr diff --git a/app/views/stays/index.html.slim b/app/views/stays/index.html.slim new file mode 100644 index 0000000..c0d0807 --- /dev/null +++ b/app/views/stays/index.html.slim @@ -0,0 +1,13 @@ +- content_for :page_header do + = render "layouts/components/page_header", + title: "Séjours", + links: [ \ + link_to("Séjour passés", past_stays_path, class: "inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"), + link_to("➕ Nouveau séjour", new_stay_path, class: "inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2") \ + ] + += render "rooms/tooltips" + += render "stays/table", + stays: @stays + diff --git a/app/views/stays/new.html.slim b/app/views/stays/new.html.slim new file mode 100644 index 0000000..737ed65 --- /dev/null +++ b/app/views/stays/new.html.slim @@ -0,0 +1,13 @@ +- content_for :page_header do + = render 'layouts/components/page_header', + title: "Nouveau séjour" + += form_with model: @stay, + url: stay_path(@stay), + data: { controller: "stay", "stay-id-value": @stay.id } do |f| + .space-y-8.divide-y.divide-gray-200.sm:space-y-5 + .space-y-6.sm:space-y-5 + = render "form", f: f + + = f.actions do + = f.submit "Enregistrer" diff --git a/app/views/stays/past.html.slim b/app/views/stays/past.html.slim new file mode 100644 index 0000000..f8fc161 --- /dev/null +++ b/app/views/stays/past.html.slim @@ -0,0 +1,14 @@ +- content_for :page_header do + = render "layouts/components/page_header", + title: "Séjours passés", + links: [ \ + link_to("Séjours à venir", stays_path, class: "inline-flex items-center rounded-md border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2") \ + ] + += render "rooms/tooltips" + += render "stays/table", + stays: @stays + +.mt-2 + = will_paginate @stays \ No newline at end of file diff --git a/app/views/stays/show.html.slim b/app/views/stays/show.html.slim new file mode 100644 index 0000000..0922166 --- /dev/null +++ b/app/views/stays/show.html.slim @@ -0,0 +1,202 @@ +- content_for :page_header do + = render 'layouts/components/page_header', + title: @stay.group_or_name, + secondary: "Du #{@stay.start_date} au #{@stay.end_date}", + links: !@stay.deleted? ? [ \ + link_to("🗓 Agenda", root_path(date: @stay.object.start_date), class: "btn-page-header-no-background"), + link_to("Dupliquer", new_stay_path(source_stay_id: @stay.id), class: "btn-page-header-no-background"), + delete_link(@stay.object, "Supprimer cette réservation"), + link_to( \ + "Mettre à jour", + edit_stay_path(@stay), + class: "btn-page-header" \ + ) \ + ] : nil + +- if @stay.deleted? + .rounded-md.bg-red-50.p-4.mb-4 + .flex + .flex-shrink-0 + svg.h-5.w-5.text-red-400[viewbox="0 0 20 20" fill="currentColor" aria-hidden="true"] + path[fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.28 7.22a.75.75 0 00-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 101.06 1.06L10 11.06l1.72 1.72a.75.75 0 101.06-1.06L11.06 10l1.72-1.72a.75.75 0 00-1.06-1.06L10 8.94 8.28 7.22z" clip-rule="evenodd"] + .ml-3 + h3.text-sm.font-medium.text-red-800 + | Ce séjour a été supprimé. +- else + = render "stays/status_callout", + stay: @stay + +.grid.sm:grid-cols-1.md:grid-cols-3.gap-4 + .md:col-span-2.space-y-4 + .overflow-hidden.bg-white.shadow.sm:rounded-lg + .px-4.py-5.sm:px-6 + h3.text-lg.font-medium.leading-6.text-gray-900 + | Composition du séjour + / p.mt-1.max-w-2xl.text-sm.text-gray-500 + | Chambres + .border-t.border-gray-200 + / dl + - @lodgings.each do |lodging| + .px-4.py-5.sm:grid.sm:grid-cols-3.sm:gap-4.sm:px-6 + dt.text-sm.font-medium.text-gray-500 + | Hébergement de groupe + dd.mt-1.text-sm.text-gray-900.sm:col-span-2.sm:mt-0 + = lodging.name + .px-4.py-5.sm:grid.sm:grid-cols-3.sm:gap-4.sm:px-6 + dt.text-sm.font-medium.text-gray-500 + | Chambres et dates + dd.mt-1.text-sm.text-gray-900.sm:col-span-2.sm:mt-0 + table + tbody + - @reservations_by_date.each do |date, items| + tr + td.align-top(style="width: 10rem") + = link_to date, + day_details_path(date: items.first.object.booking_date.strftime("%Y-%m-%d")), data: { turbo_frame: "modal" }, + class: "text-blue-500 border-b-2 border-blue-200 hover:text-blue-700 focus:text-blue-700" + td.align-top = items.collect { |i| i.booked_item.name }.join(", ") + + + .inline-block.min-w-full.py-2.sm:px-6.lg:px-8 + span.isolate.inline-flex.rounded-md.shadow-sm + = render partial: "stays/stay_items_links", + locals: { stay: @stay } + + table.min-w-full.divide-y.divide-gray-300 + tbody#stay-items.divide-y.divide-gray-200.align-top + = render partial: "stay_items/stay_item", + collection: @stay.stay_items.order_by_item_type.decorate, + as: :stay_item + tfoot + tr.text-base.font-medium.text-gray-500.text-xl.text-4s-main + td(colspan="2")   + = render partial: "stays/total_amount", + locals: {total_amount: @stay.total_reservation_amount} + + - if @stay.comments.presence + .overflow-hidden.bg-white.shadow.sm:rounded-lg + .px-4.py-5.sm:px-6 + h3.text-lg.font-medium.leading-6.text-gray-900 + | Informations complémentaires + / p.mt-1.max-w-2xl.text-sm.text-gray-500 + | Nom et coordonnées de la personne qui a réservé + .border-t.border-gray-200.px-6.py-4 + .text-gray-500.text-sm.mb-2 + | Ces informations ont été fournies par le client lors de sa demande de réservation. + .content.space-y-4 + == simple_format @stay.comments + + / Payments + .overflow-hidden.bg-white.shadow.sm:rounded-lg + .px-4.py-5.sm:px-6.flex.place-content-between + .flex.space-x-2 + h3.text-lg.font-medium.leading-6.text-gray-900 + | Paiements + div = @stay.payment_status + + = link_to "Nouveau paiement ➕", + new_stay_payment_path(stay_id: @stay.id), data: { turbo_frame: "modal" }, + class: "text-blue-500 border-b-2 border-blue-200 hover:text-blue-700 focus:text-blue-700" + .border-t-4(class="border-#{@stay.payment_status_color}") + table.table-fixed.w-full.text-sm.text-left.text-gray-900.border-b.border-b-gray-200 + thead.text-xs.text-gray-700.uppercase.bg-gray-50 + tr + th.py-3.px-3.w-8[scope="col"] + |   + th.py-3.px-6.w-32[scope="col"] + | Date + th.py-3.px-6.w-36[scope="col"] + | Montant + th.py-3.px-6.w-48[scope="col"] + | Statut + th.py-3.px-6.hide-if-stay-page[scope="col"] + | Réservation + th.py-3.px-6[scope="col"]   + tbody.bg-white(id="payments-#{@stay.id}") + - @stay.payments.decorate.each do |payment| + = render '/payments/stay_payments', payment: payment + tfoot + = render partial: "payments/sum", locals: { reservation: @stay } + dl.my-4.px-6 + .py-2.sm:grid.sm:grid-cols-3.sm:gap-4 + dt.text-sm.font-medium.text-gray-500 + | Tarif du séjour + dd.mt-1.text-sm.text-gray-900.sm:col-span-2.sm:mt-0 + span.font-semibold = @stay.total_amount + .py-2.sm:grid.sm:grid-cols-3.sm:gap-4 + dt.text-sm.font-medium.text-gray-500 + | Reste à payer + dd.mt-1.text-sm.text-gray-900.sm:col-span-2.sm:mt-0 + = @stay.total_remaining_amount + .py-2.sm:grid.sm:grid-cols-3.sm:gap-4 + dt.text-sm.font-medium.text-gray-500 + | Facture + dd.mt-1.text-sm.text-gray-900.sm:col-span-2.sm:mt-0 + = @stay.invoice_status + + / Options + / .overflow-hidden.bg-white.shadow.sm:rounded-lg + .px-4.py-5.sm:px-6 + h3.text-lg.font-medium.leading-6.text-gray-900 + | Options + / p.mt-1.max-w-2xl.text-sm.text-gray-500 + | Nom et coordonnées de la personne qui a réservé + .border-t.border-gray-200 + / dl + - unless @spaces_by_date.empty? + = render '/stays/show/options', option_label: 'Espace(s)', items_by_date: @spaces_by_date + + - unless @experiences_by_date.empty? + = render '/stays/show/options', option_label: 'Activité(s)', items_by_date: @experiences_by_date + + - unless @products_by_date.empty? + = render '/stays/show/options', option_label: 'Produit(s)', items_by_date: @products_by_date + + - unless @rental_items_by_date.empty? + = render '/stays/show/options', option_label: 'Object(s) de location', items_by_date: @rental_items_by_date + + - if @stay.legacy_booking_id.present? || @stay.legacy_space_booking_id.present? + .mt-4.px-6.py-3.bg-blue-50.rounded-md.text-blue-900.text-xs.opacity-75 + | Ce séjour a été migré sur base des réservations suivantes : + - if @stay.legacy_booking_id.present? + =< link_to "Hébergement ##{@stay.legacy_booking_id}", booking_path(@stay.legacy_booking_id), class: "underline text-blue-700 hover:text-blue-900" + - if @stay.legacy_booking_id.present? && @stay.legacy_space_booking_id.present? + | , + - if @stay.legacy_space_booking_id.present? + =< link_to "Espace(s) ##{@stay.legacy_space_booking_id}", space_booking_path(@stay.legacy_space_booking_id), class: "underline text-blue-700 hover:text-blue-900" + + .space-y-4 + = render partial: "stays/show/details", locals: { stay: @stay } + + = @stay.people_emojis + + - if @stay.notes.presence + .overflow-hidden.bg-yellow-100.shadow.sm:rounded-lg + / .px-4.py-3.sm:px-6.bg-yellow-200 + h3.text-lg.font-bold.leading-6.text-yellow-900 + | Notes internes + p.mt-1.max-w-2xl.text-sm.text-yellow-500 + | Informations à usage interne + .px-6.py-4 + .space-y-4.font-caveat.text-xl.text-yellow-600 + = simple_format @stay.notes + + - if @stay.estimated_arrival.presence || @stay.departure_time.presence + div + dl.grid.grid-cols-2.gap-5 + - if @stay.estimated_arrival.presence + .overflow-hidden.rounded-lg.bg-white.px-4.py-5.shadow.sm:p-6 + dt.truncate.text-sm.font-medium.text-gray-500 + | Arrivée + dd.mt-1.text-3xl.font-semibold.tracking-tight.text-gray-900 + = @stay.estimated_arrival + / span.text-lg.ml-1.text-gray-500 + = @stay.start_date + - if @stay.departure_time.presence + .overflow-hidden.rounded-lg.bg-white.px-4.py-5.shadow.sm:p-6 + dt.truncate.text-sm.font-medium.text-gray-500 + | Départ + dd.mt-1.text-3xl.font-semibold.tracking-tight.text-gray-900 + = @stay.departure_time + / span.text-lg.ml-1.text-gray-500 + = @stay.end_date diff --git a/app/views/stays/show/_details.html.slim b/app/views/stays/show/_details.html.slim new file mode 100644 index 0000000..b5f7a8b --- /dev/null +++ b/app/views/stays/show/_details.html.slim @@ -0,0 +1,30 @@ + div + .flex.place-content-between.py-2.mb-4.border-b.border-gray-500 + h3.text-lg.font-semibold.leading-6.text-gray-900 + | Séjour <code>##{stay.token}</code> + = link_to "Page web du séjour 🔗", + public_stay_path(stay.object.token), + class: "claudy-link", + target: "_blank" + .mt-4.content.space-y-4.text-sm + .font-bold.text-xl + = link_to @stay.group_or_name, customer_path(@stay.customer), class: "claudy-link" + / - if @stay.name + .truncate.text-sm.font-semibold.text-gray-500 + = @stay.name + + div + - if @stay.customer&.email.presence + div + a.inline-flex.gap-x-2.claudy-link[href="mailto:#{@stay.customer&.email}"] + svg.h-5.w-5.text-gray-400[viewbox="0 0 20 20" fill="currentColor" aria-hidden="true"] + path[d="M3 4a2 2 0 00-2 2v1.161l8.441 4.221a1.25 1.25 0 001.118 0L19 7.162V6a2 2 0 00-2-2H3z"] + path[d="M19 8.839l-7.77 3.885a2.75 2.75 0 01-2.46 0L1 8.839V14a2 2 0 002 2h14a2 2 0 002-2V8.839z"] + = @stay.customer&.email + - if @stay.customer&.phone.presence + div + a.inline-flex.gap-x-2.claudy-link[href="tel:#{@stay.customer&.phone}"] + svg.h-5.w-5.text-gray-400[viewbox="0 0 20 20" fill="currentColor" aria-hidden="true"] + path[fill-rule="evenodd" d="M2 3.5A1.5 1.5 0 013.5 2h1.148a1.5 1.5 0 011.465 1.175l.716 3.223a1.5 1.5 0 01-1.052 1.767l-.933.267c-.41.117-.643.555-.48.95a11.542 11.542 0 006.254 6.254c.395.163.833-.07.95-.48l.267-.933a1.5 1.5 0 011.767-1.052l3.223.716A1.5 1.5 0 0118 15.352V16.5a1.5 1.5 0 01-1.5 1.5H15c-1.149 0-2.263-.15-3.326-.43A13.022 13.022 0 012.43 8.326 13.019 13.019 0 012 5V3.5z" clip-rule="evenodd"] + = @stay.customer&.phone + \ No newline at end of file diff --git a/app/views/stays/show/_options.html.slim b/app/views/stays/show/_options.html.slim new file mode 100644 index 0000000..bf67763 --- /dev/null +++ b/app/views/stays/show/_options.html.slim @@ -0,0 +1,13 @@ +.px-4.py-5.sm:grid.sm:grid-cols-3.sm:gap-4.sm:px-6 + dt.text-sm.font-medium.text-gray-500 + = option_label + dd.mt-1.text-sm.text-gray-900.sm:col-span-2.sm:mt-0 + table + tbody + - items_by_date.each do |date, items| + tr + td.align-top(style="width: 10rem") + = link_to date, + day_details_path(date: date.strftime("%Y-%m-%d")), data: { turbo_frame: "modal" }, + class: "text-blue-500 border-b-2 border-blue-200 hover:text-blue-700 focus:text-blue-700" + td.align-top = items.collect { |i| i.name }.join(", ") \ No newline at end of file diff --git a/config/database.yml b/config/database.yml index 0c41335..7d634a3 100644 --- a/config/database.yml +++ b/config/database.yml @@ -5,7 +5,7 @@ default: &default development: <<: *default - database: claudy_development-20241010 + database: claudy_development-stays test: <<: *default diff --git a/config/environments/development.rb b/config/environments/development.rb index 0de8436..d5b2f5b 100644 --- a/config/environments/development.rb +++ b/config/environments/development.rb @@ -17,6 +17,7 @@ # Enable server timing config.server_timing = true + # Enable/disable caching. By default caching is disabled. # Run rails dev:cache to toggle caching. if Rails.root.join("tmp/caching-dev.txt").exist? diff --git a/config/importmap.rb b/config/importmap.rb index ade43a2..2b0d651 100644 --- a/config/importmap.rb +++ b/config/importmap.rb @@ -9,3 +9,5 @@ pin "jquery" # @3.6.1 pin "trix" pin "@rails/actiontext", to: "actiontext.js" +pin "select2", to: "https://cdnjs.cloudflare.com/ajax/libs/select2/4.1.0-rc.0/js/select2.min.js" +pin "select2-css", to: "https://cdnjs.cloudflare.com/ajax/libs/select2/4.1.0-rc.0/css/select2.min.css" diff --git a/config/initializers/content_security_policy.rb b/config/initializers/content_security_policy.rb index 6444c72..078571d 100644 --- a/config/initializers/content_security_policy.rb +++ b/config/initializers/content_security_policy.rb @@ -18,7 +18,9 @@ policy.connect_src :self, # Allow @vite/client to hot reload CSS changes - "ws://#{ViteRuby.config.host}" + "ws://#{ViteRuby.config.host_with_port}", + "http://#{ViteRuby.config.host_with_port}", + "http://#{ViteRuby.config.host_with_port}/vite-dev/" policy.style_src :self, :https, diff --git a/config/locales/en.yml b/config/locales/en.yml index 8ca56fc..23936fd 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -31,3 +31,6 @@ en: hello: "Hello world" + date: + formats: + short_with_year: "%b %-d, %Y" diff --git a/config/locales/fr.yml b/config/locales/fr.yml index b62d135..ab6fb80 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -42,6 +42,7 @@ fr: default: "%e %B %Y" ddmmyyyy: "%d/%m/%Y" short: "%-d %B" + short_with_year: "%-d %B %Y" long: "%e %B %Y" month_year: "%B %Y" date_with_day: "%e %B (%A)" @@ -216,6 +217,7 @@ fr: default: "%d %B %Y %Hh %Mmin %Ss" long: "%A %d %B %Y (%-Hh%M)" short: "%d %b %Hh%M" + short_with_year: "%-d %B %Y %Hh%M" only_time: "%H:%M" twenty_four_hour: "%kh%M" twelve_hour: "%l:%M %P" diff --git a/config/routes.rb b/config/routes.rb index bf6509c..cfa9434 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -23,6 +23,16 @@ resources :roles resources :rooms resources :services + resources :stays do + resources :stay_items + resources :payments + collection do + get "past" + end + member do + post "save_dates" + end + end resources :tasks resources :teams @@ -40,13 +50,22 @@ get "comptabilite", to: "accounting#index", as: :accounting + get "stay_prices/calculate_item_price", to: "stay_prices#calculate_item_price", as: :calculate_item_price + get "pages/day", to: "pages#day", as: :day_details get "pages/dashboard", to: "pages#dashboard", as: :dashboard get "pages/other_bookings", to: "pages#other_bookings" get "pages/other_space_bookings", to: "pages#other_space_bookings" + get "pages/other_stays", to: "pages#other_stays" get "reports/lodging/:id", to: "reports#lodging", as: :lodging_reports + resources :customers, only: [:index, :show, :new, :create, :edit, :update] do + get 'lookup', on: :collection + get 'duplicates', on: :collection + patch 'merge_duplicates', on: :collection + end + namespace :public do resources :bookings, only: [:new, :create] do get "edit_estimated_arrival", on: :member @@ -57,6 +76,7 @@ get "pay" end end + get "stays/:token", to: "stays#show", as: :stay get "reservation/:token", to: "bookings#show", as: :booking get "espaces/:token", to: "space_bookings#show", as: :space_booking get "calendrier-hebergements", to: "calendars#lodgings" diff --git a/db/migrate/20240702120000_add_legacy_space_booking_id_to_stays.rb b/db/migrate/20240702120000_add_legacy_space_booking_id_to_stays.rb new file mode 100644 index 0000000..9523cad --- /dev/null +++ b/db/migrate/20240702120000_add_legacy_space_booking_id_to_stays.rb @@ -0,0 +1,6 @@ +class AddLegacySpaceBookingIdToStays < ActiveRecord::Migration[7.0] + def change + add_column :stays, :legacy_space_booking_id, :bigint + add_index :stays, :legacy_space_booking_id + end +end \ No newline at end of file diff --git a/db/migrate/20240729081523_create_stays.rb b/db/migrate/20240729081523_create_stays.rb new file mode 100644 index 0000000..a343c64 --- /dev/null +++ b/db/migrate/20240729081523_create_stays.rb @@ -0,0 +1,11 @@ +class CreateStays < ActiveRecord::Migration[7.0] + def change + create_table :stays do |t| + t.references :user, null: false, foreign_key: true + t.date :start_date + t.date :end_date + t.string :status + t.timestamps + end + end +end \ No newline at end of file diff --git a/db/migrate/20240729081608_create_stay_items.rb b/db/migrate/20240729081608_create_stay_items.rb new file mode 100644 index 0000000..d855d6d --- /dev/null +++ b/db/migrate/20240729081608_create_stay_items.rb @@ -0,0 +1,11 @@ +class CreateStayItems < ActiveRecord::Migration[7.0] + def change + create_table :stay_items do |t| + t.references :stay, null: false, foreign_key: true + t.references :bookable, polymorphic: true, null: false + t.integer :quantity + t.decimal :price + t.timestamps + end + end +end diff --git a/db/migrate/20240729090739_add_platform_to_stays.rb b/db/migrate/20240729090739_add_platform_to_stays.rb new file mode 100644 index 0000000..9b89555 --- /dev/null +++ b/db/migrate/20240729090739_add_platform_to_stays.rb @@ -0,0 +1,5 @@ +class AddPlatformToStays < ActiveRecord::Migration[7.0] + def change + add_column :stays, :platform, :string + end +end diff --git a/db/migrate/20240729111629_add_extra_fields_to_stays.rb b/db/migrate/20240729111629_add_extra_fields_to_stays.rb new file mode 100644 index 0000000..6eefbdf --- /dev/null +++ b/db/migrate/20240729111629_add_extra_fields_to_stays.rb @@ -0,0 +1,10 @@ +class AddExtraFieldsToStays < ActiveRecord::Migration[7.0] + def change + add_column :stays, :adults, :integer + add_column :stays, :children, :integer + add_column :stays, :babies, :integer + add_column :stays, :estimated_arrival, :string + add_column :stays, :departure_time, :string + add_column :stays, :token, :string + end +end diff --git a/db/migrate/20240729113803_create_customer.rb b/db/migrate/20240729113803_create_customer.rb new file mode 100644 index 0000000..ee9a56f --- /dev/null +++ b/db/migrate/20240729113803_create_customer.rb @@ -0,0 +1,12 @@ +class CreateCustomer < ActiveRecord::Migration[7.0] + def change + create_table :customers do |t| + t.string :firstname + t.string :lastname + t.string :phone + t.string :email + t.text :notes + t.timestamps + end + end +end diff --git a/db/migrate/20240729114056_add_customer_to_stay.rb b/db/migrate/20240729114056_add_customer_to_stay.rb new file mode 100644 index 0000000..4aebe46 --- /dev/null +++ b/db/migrate/20240729114056_add_customer_to_stay.rb @@ -0,0 +1,5 @@ +class AddCustomerToStay < ActiveRecord::Migration[7.0] + def change + add_reference :stays, :customer, null: false, foreign_key: true + end +end diff --git a/db/migrate/20240730073455_add_extra_fields_to_stay_items.rb b/db/migrate/20240730073455_add_extra_fields_to_stay_items.rb new file mode 100644 index 0000000..fb8f437 --- /dev/null +++ b/db/migrate/20240730073455_add_extra_fields_to_stay_items.rb @@ -0,0 +1,11 @@ +class AddExtraFieldsToStayItems < ActiveRecord::Migration[7.0] + def change + add_column :stay_items, :start_date, :date + add_column :stay_items, :end_date, :date + add_column :stay_items, :duration, :string + add_column :stay_items, :notes, :text + add_column :stay_items, :adults, :integer + add_column :stay_items, :children, :integer + add_column :stay_items, :babies, :integer + end +end diff --git a/db/migrate/20240730084235_create_beds.rb b/db/migrate/20240730084235_create_beds.rb new file mode 100644 index 0000000..bd78f92 --- /dev/null +++ b/db/migrate/20240730084235_create_beds.rb @@ -0,0 +1,13 @@ +class CreateBeds < ActiveRecord::Migration[7.0] + def change + create_table :beds do |t| + t.string :name + t.text :description + t.integer :price_cents + t.references :room, foreign_key: true, index: true + t.timestamps + end + end +end + + diff --git a/db/migrate/20240731094025_drop_stay_items.rb b/db/migrate/20240731094025_drop_stay_items.rb new file mode 100644 index 0000000..8b2d26c --- /dev/null +++ b/db/migrate/20240731094025_drop_stay_items.rb @@ -0,0 +1,5 @@ +class DropStayItems < ActiveRecord::Migration[7.0] + def change + drop_table :stay_items + end +end diff --git a/db/migrate/20240731094148_create_new_stay_items.rb b/db/migrate/20240731094148_create_new_stay_items.rb new file mode 100644 index 0000000..c44cb35 --- /dev/null +++ b/db/migrate/20240731094148_create_new_stay_items.rb @@ -0,0 +1,16 @@ +class CreateNewStayItems < ActiveRecord::Migration[7.0] + def change + create_table :stay_items do |t| + t.references :stay, null: false, foreign_key: true + t.references :item, polymorphic: true, null: false + t.date :start_date, null: false + t.date :end_date, null: false + t.integer :quantity, default: 1 + t.decimal :unit_price, precision: 10, scale: 2 + t.integer :adults_count + t.integer :children_count + t.string :duration + t.timestamps + end + end +end diff --git a/db/migrate/20240801075733_add_deleted_at_to_stays.rb b/db/migrate/20240801075733_add_deleted_at_to_stays.rb new file mode 100644 index 0000000..eed1ab0 --- /dev/null +++ b/db/migrate/20240801075733_add_deleted_at_to_stays.rb @@ -0,0 +1,5 @@ +class AddDeletedAtToStays < ActiveRecord::Migration[7.0] + def change + add_column :stays, :deleted_at, :timestamp + end +end diff --git a/db/migrate/20240801091948_add_comments_to_stay.rb b/db/migrate/20240801091948_add_comments_to_stay.rb new file mode 100644 index 0000000..eef2ccc --- /dev/null +++ b/db/migrate/20240801091948_add_comments_to_stay.rb @@ -0,0 +1,5 @@ +class AddCommentsToStay < ActiveRecord::Migration[7.0] + def change + add_column :stays, :comments, :text + end +end diff --git a/db/migrate/20240801111630_add_stay_to_payments.rb b/db/migrate/20240801111630_add_stay_to_payments.rb new file mode 100644 index 0000000..5466d08 --- /dev/null +++ b/db/migrate/20240801111630_add_stay_to_payments.rb @@ -0,0 +1,6 @@ +class AddStayToPayments < ActiveRecord::Migration[7.0] + def change + add_reference :payments, :stay, null: true, foreign_key: true + change_column_null :payments, :booking_id, true + end +end diff --git a/db/migrate/20240801114823_add_notes_to_stay.rb b/db/migrate/20240801114823_add_notes_to_stay.rb new file mode 100644 index 0000000..f5d4171 --- /dev/null +++ b/db/migrate/20240801114823_add_notes_to_stay.rb @@ -0,0 +1,5 @@ +class AddNotesToStay < ActiveRecord::Migration[7.0] + def change + add_column :stays, :notes, :text + end +end diff --git a/db/migrate/20240805075241_create_payment_requests.rb b/db/migrate/20240805075241_create_payment_requests.rb new file mode 100644 index 0000000..54b34c4 --- /dev/null +++ b/db/migrate/20240805075241_create_payment_requests.rb @@ -0,0 +1,12 @@ +class CreatePaymentRequests < ActiveRecord::Migration[7.0] + def change + create_table :payment_requests do |t| + t.references :stay, null: false, foreign_key: true + t.integer :status, default: 0 + + t.timestamps + end + + add_monetize :payment_requests, :amount, amount: { null: false }, currency: { present: false } + end +end diff --git a/db/migrate/20240805075726_create_payment_requests_stay_items.rb b/db/migrate/20240805075726_create_payment_requests_stay_items.rb new file mode 100644 index 0000000..028bf8c --- /dev/null +++ b/db/migrate/20240805075726_create_payment_requests_stay_items.rb @@ -0,0 +1,10 @@ +class CreatePaymentRequestsStayItems < ActiveRecord::Migration[7.0] + def change + create_table :payment_requests_stay_items do |t| + t.references :payment_request, null: false, foreign_key: true + t.references :stay_item, null: false, foreign_key: true + + t.timestamps + end + end +end diff --git a/db/migrate/20240805075922_add_payment_request_reference_to_payments.rb b/db/migrate/20240805075922_add_payment_request_reference_to_payments.rb new file mode 100644 index 0000000..0417cce --- /dev/null +++ b/db/migrate/20240805075922_add_payment_request_reference_to_payments.rb @@ -0,0 +1,5 @@ +class AddPaymentRequestReferenceToPayments < ActiveRecord::Migration[7.0] + def change + add_reference :payments, :payment_request, null: true, foreign_key: true + end +end diff --git a/db/migrate/20240805091429_change_unit_price_to_unit_price_cents.rb b/db/migrate/20240805091429_change_unit_price_to_unit_price_cents.rb new file mode 100644 index 0000000..a8be069 --- /dev/null +++ b/db/migrate/20240805091429_change_unit_price_to_unit_price_cents.rb @@ -0,0 +1,7 @@ +class ChangeUnitPriceToUnitPriceCents < ActiveRecord::Migration[7.0] + + def change + add_monetize :stay_items, :unit_price, default: 0, null: false + end + +end \ No newline at end of file diff --git a/db/migrate/20240805091956_remove_old_unit_price_from_stay_items.rb b/db/migrate/20240805091956_remove_old_unit_price_from_stay_items.rb new file mode 100644 index 0000000..7b6b788 --- /dev/null +++ b/db/migrate/20240805091956_remove_old_unit_price_from_stay_items.rb @@ -0,0 +1,5 @@ +class RemoveOldUnitPriceFromStayItems < ActiveRecord::Migration[7.0] + def change + remove_column :stay_items, :unit_price, :decimal + end +end diff --git a/db/migrate/20240805101756_add_invoice_and_payment_status_to_payment_requests.rb b/db/migrate/20240805101756_add_invoice_and_payment_status_to_payment_requests.rb new file mode 100644 index 0000000..3304b8f --- /dev/null +++ b/db/migrate/20240805101756_add_invoice_and_payment_status_to_payment_requests.rb @@ -0,0 +1,6 @@ +class AddInvoiceAndPaymentStatusToPaymentRequests < ActiveRecord::Migration[7.0] + def change + add_column :payment_requests, :invoice_status, :string + add_column :payment_requests, :payment_status, :string + end +end diff --git a/db/migrate/20240812160047_add_draft_to_stays.rb b/db/migrate/20240812160047_add_draft_to_stays.rb new file mode 100644 index 0000000..2db0d8d --- /dev/null +++ b/db/migrate/20240812160047_add_draft_to_stays.rb @@ -0,0 +1,5 @@ +class AddDraftToStays < ActiveRecord::Migration[7.0] + def change + add_column :stays, :draft, :boolean, default: true + end +end diff --git a/db/migrate/20240812160639_change_customer_id_for_stays.rb b/db/migrate/20240812160639_change_customer_id_for_stays.rb new file mode 100644 index 0000000..180da48 --- /dev/null +++ b/db/migrate/20240812160639_change_customer_id_for_stays.rb @@ -0,0 +1,5 @@ +class ChangeCustomerIdForStays < ActiveRecord::Migration[7.0] + def change + change_column_null :stays, :customer_id, true + end +end diff --git a/db/migrate/20240816073915_drop_payment_requests.rb b/db/migrate/20240816073915_drop_payment_requests.rb new file mode 100644 index 0000000..cf07907 --- /dev/null +++ b/db/migrate/20240816073915_drop_payment_requests.rb @@ -0,0 +1,11 @@ +class DropPaymentRequests < ActiveRecord::Migration[7.0] + + + def change + execute "ALTER TABLE payments DROP CONSTRAINT fk_rails_d6c292006a CASCADE" + drop_table :payment_requests_stay_items + drop_table :payment_requests + end + + +end diff --git a/db/migrate/20240816080709_add_payment_status_to_stay.rb b/db/migrate/20240816080709_add_payment_status_to_stay.rb new file mode 100644 index 0000000..91bb5d4 --- /dev/null +++ b/db/migrate/20240816080709_add_payment_status_to_stay.rb @@ -0,0 +1,5 @@ +class AddPaymentStatusToStay < ActiveRecord::Migration[7.0] + def change + add_column :stays, :payment_status, :string + end +end diff --git a/db/migrate/20240816081447_add_invoice_status_to_stay.rb b/db/migrate/20240816081447_add_invoice_status_to_stay.rb new file mode 100644 index 0000000..20b9f14 --- /dev/null +++ b/db/migrate/20240816081447_add_invoice_status_to_stay.rb @@ -0,0 +1,5 @@ +class AddInvoiceStatusToStay < ActiveRecord::Migration[7.0] + def change + add_column :stays, :invoice_status, :string + end +end diff --git a/db/migrate/20240816093545_add_group_name_to_stay.rb b/db/migrate/20240816093545_add_group_name_to_stay.rb new file mode 100644 index 0000000..2dac6ee --- /dev/null +++ b/db/migrate/20240816093545_add_group_name_to_stay.rb @@ -0,0 +1,5 @@ +class AddGroupNameToStay < ActiveRecord::Migration[7.0] + def change + add_column :stays, :group_name, :string + end +end diff --git a/db/migrate/20240818072241_add_babies_count_to_stay_item.rb b/db/migrate/20240818072241_add_babies_count_to_stay_item.rb new file mode 100644 index 0000000..e657917 --- /dev/null +++ b/db/migrate/20240818072241_add_babies_count_to_stay_item.rb @@ -0,0 +1,5 @@ +class AddBabiesCountToStayItem < ActiveRecord::Migration[7.0] + def change + add_column :stay_items, :babies_count, :integer + end +end diff --git a/db/migrate/20240819064514_create_stay_item_dates.rb b/db/migrate/20240819064514_create_stay_item_dates.rb new file mode 100644 index 0000000..4f8c3a5 --- /dev/null +++ b/db/migrate/20240819064514_create_stay_item_dates.rb @@ -0,0 +1,15 @@ +class CreateStayItemDates < ActiveRecord::Migration[7.0] + def change + create_table :stay_item_dates do |t| + + t.references :booked_item, polymorphic: true, null: false + t.date :booking_date, null: false + t.references :stay, null: false, foreign_key: true + + t.timestamps + end + + add_index :stay_item_dates, [:booked_item_type, :booked_item_id, :booking_date], unique: true, name: 'index_stay_item_dates_on_item_and_date' + + end +end diff --git a/db/migrate/20240819112902_remove_index_from_stay_item_dates.rb b/db/migrate/20240819112902_remove_index_from_stay_item_dates.rb new file mode 100644 index 0000000..8b28b3b --- /dev/null +++ b/db/migrate/20240819112902_remove_index_from_stay_item_dates.rb @@ -0,0 +1,5 @@ +class RemoveIndexFromStayItemDates < ActiveRecord::Migration[7.0] + def change + remove_index :stay_item_dates, name: "index_stay_item_dates_on_item_and_date" + end +end diff --git a/db/migrate/20240819121647_add_direct_book_to_stay_item_dates.rb b/db/migrate/20240819121647_add_direct_book_to_stay_item_dates.rb new file mode 100644 index 0000000..f7fc509 --- /dev/null +++ b/db/migrate/20240819121647_add_direct_book_to_stay_item_dates.rb @@ -0,0 +1,5 @@ +class AddDirectBookToStayItemDates < ActiveRecord::Migration[7.0] + def change + add_column :stay_item_dates, :direct_book, :boolean, default: true + end +end diff --git a/db/migrate/20240820115037_add_public_notes_to_stays.rb b/db/migrate/20240820115037_add_public_notes_to_stays.rb new file mode 100644 index 0000000..74299ca --- /dev/null +++ b/db/migrate/20240820115037_add_public_notes_to_stays.rb @@ -0,0 +1,5 @@ +class AddPublicNotesToStays < ActiveRecord::Migration[7.0] + def change + add_column :stays, :public_notes, :text + end +end diff --git a/db/migrate/20240827094044_add_price_night_to_rooms.rb b/db/migrate/20240827094044_add_price_night_to_rooms.rb new file mode 100644 index 0000000..8c23fde --- /dev/null +++ b/db/migrate/20240827094044_add_price_night_to_rooms.rb @@ -0,0 +1,5 @@ +class AddPriceNightToRooms < ActiveRecord::Migration[7.0] + def change + add_monetize :rooms, :price_night, currency: { present: false } + end +end diff --git a/db/migrate/20240828081920_add_calculated_price_to_stay_items.rb b/db/migrate/20240828081920_add_calculated_price_to_stay_items.rb new file mode 100644 index 0000000..dcceb46 --- /dev/null +++ b/db/migrate/20240828081920_add_calculated_price_to_stay_items.rb @@ -0,0 +1,5 @@ +class AddCalculatedPriceToStayItems < ActiveRecord::Migration[7.0] + def change + add_monetize :stay_items, :calculated_price, currency: { present: false } + end +end diff --git a/db/migrate/20240831144344_add_final_price_to_stays.rb b/db/migrate/20240831144344_add_final_price_to_stays.rb new file mode 100644 index 0000000..414486c --- /dev/null +++ b/db/migrate/20240831144344_add_final_price_to_stays.rb @@ -0,0 +1,6 @@ +class AddFinalPriceToStays < ActiveRecord::Migration[7.0] + def change + add_monetize :stays, :final_price, currency: { present: false } + end +end + diff --git a/db/migrate/20240908102936_add_stay_item_id_to_stay_item_date.rb b/db/migrate/20240908102936_add_stay_item_id_to_stay_item_date.rb new file mode 100644 index 0000000..d897c16 --- /dev/null +++ b/db/migrate/20240908102936_add_stay_item_id_to_stay_item_date.rb @@ -0,0 +1,7 @@ +class AddStayItemIdToStayItemDate < ActiveRecord::Migration[7.0] + + def change + add_reference :stay_item_dates, :stay_item, null: true, foreign_key: true + end + +end diff --git a/db/migrate/20250708160715_add_company_and_address_fields_to_customers.rb b/db/migrate/20250708160715_add_company_and_address_fields_to_customers.rb new file mode 100644 index 0000000..7c028a7 --- /dev/null +++ b/db/migrate/20250708160715_add_company_and_address_fields_to_customers.rb @@ -0,0 +1,12 @@ +class AddCompanyAndAddressFieldsToCustomers < ActiveRecord::Migration[7.0] + def change + add_column :customers, :company_name, :string + add_column :customers, :vat_number, :string + add_column :customers, :street, :string + add_column :customers, :number, :string + add_column :customers, :box, :string + add_column :customers, :postcode, :string + add_column :customers, :city, :string + add_column :customers, :country, :string, default: "Belgique" + end +end diff --git a/db/migrate/20250708190626_add_legacy_booking_id_to_stays.rb b/db/migrate/20250708190626_add_legacy_booking_id_to_stays.rb new file mode 100644 index 0000000..a96438c --- /dev/null +++ b/db/migrate/20250708190626_add_legacy_booking_id_to_stays.rb @@ -0,0 +1,6 @@ +class AddLegacyBookingIdToStays < ActiveRecord::Migration[7.0] + def change + add_column :stays, :legacy_booking_id, :bigint, comment: "Référence vers l'ancien booking migré" + add_index :stays, :legacy_booking_id, name: "index_stays_on_legacy_booking_id" + end +end diff --git a/db/schema.rb b/db/schema.rb index 255b0b5..1665d73 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,9 +10,8 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.0].define(version: 2024_03_28_161654) do +ActiveRecord::Schema[7.0].define(version: 2025_07_08_190626) do # These are extensions that must be enabled in order to support this database - enable_extension "pgcrypto" enable_extension "plpgsql" create_table "action_text_rich_texts", force: :cascade do |t| @@ -20,8 +19,8 @@ t.text "body" t.string "record_type", null: false t.bigint "record_id", null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false t.index ["record_type", "record_id", "name"], name: "index_action_text_rich_texts_uniqueness", unique: true end @@ -30,7 +29,7 @@ t.string "record_type", null: false t.bigint "record_id", null: false t.bigint "blob_id", null: false - t.datetime "created_at", null: false + t.datetime "created_at", precision: nil, null: false t.index ["blob_id"], name: "index_active_storage_attachments_on_blob_id" t.index ["record_type", "record_id", "name", "blob_id"], name: "index_active_storage_attachments_uniqueness", unique: true end @@ -43,7 +42,7 @@ t.string "service_name", null: false t.bigint "byte_size", null: false t.string "checksum" - t.datetime "created_at", null: false + t.datetime "created_at", precision: nil, null: false t.index ["key"], name: "index_active_storage_blobs_on_key", unique: true end @@ -72,6 +71,16 @@ t.index ["trackable_type", "trackable_id"], name: "index_activities_on_trackable_type_and_trackable_id" end + create_table "beds", force: :cascade do |t| + t.string "name" + t.text "description" + t.integer "price_cents" + t.bigint "room_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["room_id"], name: "index_beds_on_room_id" + end + create_table "bookings", force: :cascade do |t| t.string "firstname" t.string "lastname" @@ -87,8 +96,8 @@ t.boolean "bedsheets" t.boolean "towels" t.text "notes" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false t.integer "price_cents" t.string "invoice_status" t.string "contract_status" @@ -118,28 +127,46 @@ t.integer "position" t.bigint "project_id" t.bigint "team_id" - t.datetime "deleted_at" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.datetime "deleted_at", precision: nil + t.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false t.index ["project_id"], name: "index_bundles_on_project_id" t.index ["team_id"], name: "index_bundles_on_team_id" end + create_table "customers", force: :cascade do |t| + t.string "firstname" + t.string "lastname" + t.string "phone" + t.string "email" + t.text "notes" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.string "company_name" + t.string "vat_number" + t.string "street" + t.string "number" + t.string "box" + t.string "postcode" + t.string "city" + t.string "country", default: "Belgique" + end + create_table "event_categories", force: :cascade do |t| t.string "name" t.string "color" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false t.datetime "deleted_at", precision: nil end create_table "events", force: :cascade do |t| t.string "name" t.bigint "event_category_id", null: false - t.datetime "starts_at" - t.datetime "ends_at" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.datetime "starts_at", precision: nil + t.datetime "ends_at", precision: nil + t.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false t.datetime "deleted_at", precision: nil t.string "url" t.integer "sales_amount_cents" @@ -156,8 +183,8 @@ t.text "description" t.string "photo" t.datetime "deleted_at", precision: nil - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false t.integer "price_cents" t.integer "fixed_price_cents", default: 0 t.integer "min_participants" @@ -170,8 +197,8 @@ t.bigint "human_id", null: false t.bigint "role_id", null: false t.date "date" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false t.index ["human_id"], name: "index_human_roles_on_human_id" t.index ["role_id"], name: "index_human_roles_on_role_id" end @@ -183,8 +210,9 @@ t.string "summary" t.text "description" t.datetime "deleted_at", precision: nil - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false + t.string "status", default: "active" end create_table "humans_tasks", id: false, force: :cascade do |t| @@ -197,8 +225,8 @@ create_table "lodging_rooms", force: :cascade do |t| t.bigint "lodging_id", null: false t.bigint "room_id", null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false t.index ["lodging_id"], name: "index_lodging_rooms_on_lodging_id" t.index ["room_id"], name: "index_lodging_rooms_on_room_id" end @@ -206,37 +234,42 @@ create_table "lodgings", force: :cascade do |t| t.string "name" t.text "description" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false t.string "summary" t.integer "price_night_cents", default: 0, null: false t.boolean "party_hall_availability" t.integer "weekend_discount_cents", default: 0, null: false t.datetime "deleted_at", precision: nil t.boolean "show_on_reports", default: true + t.boolean "available_for_bookings" end create_table "notes", force: :cascade do |t| t.text "body" t.date "date" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false t.datetime "deleted_at", precision: nil t.string "color" end create_table "payments", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| - t.bigint "booking_id", null: false + t.bigint "booking_id" t.string "payment_method" t.string "status" t.datetime "deleted_at", precision: nil - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false t.integer "amount_cents", default: 0, null: false t.string "stripe_checkout_session_id" t.string "stripe_payment_intent_id" + t.bigint "stay_id" + t.bigint "payment_request_id" t.index ["booking_id"], name: "index_payments_on_booking_id" t.index ["id"], name: "index_payments_on_id", unique: true + t.index ["payment_request_id"], name: "index_payments_on_payment_request_id" + t.index ["stay_id"], name: "index_payments_on_stay_id" end create_table "products", force: :cascade do |t| @@ -245,8 +278,8 @@ t.string "photo" t.text "description" t.datetime "deleted_at", precision: nil - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false t.integer "price_cents" end @@ -256,8 +289,8 @@ t.date "due_date" t.bigint "human_id", null: false t.datetime "deleted_at", precision: nil - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false t.index ["human_id"], name: "index_projects_on_human_id" end @@ -266,17 +299,17 @@ t.integer "stock" t.string "photo" t.text "description" - t.datetime "deleted_at" + t.datetime "deleted_at", precision: nil t.integer "price_cents" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false end create_table "reservations", force: :cascade do |t| t.bigint "booking_id", null: false t.bigint "room_id", null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false t.date "date" t.datetime "deleted_at", precision: nil t.index ["booking_id"], name: "index_reservations_on_booking_id" @@ -285,9 +318,9 @@ create_table "roles", force: :cascade do |t| t.string "name" - t.datetime "deleted_at" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.datetime "deleted_at", precision: nil + t.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false t.jsonb "role_team", default: [] t.index ["role_team"], name: "index_roles_on_role_team", using: :gin end @@ -295,11 +328,12 @@ create_table "rooms", force: :cascade do |t| t.string "name" t.text "description" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false t.integer "level" t.string "code" t.datetime "deleted_at", precision: nil + t.integer "price_night_cents", default: 0, null: false end create_table "services", force: :cascade do |t| @@ -309,8 +343,8 @@ t.text "description" t.string "photo" t.datetime "deleted_at", precision: nil - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false t.integer "price_cents" t.index ["human_id"], name: "index_services_on_human_id" end @@ -330,8 +364,8 @@ t.string "contract_status" t.text "notes" t.string "token" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false t.integer "price_cents" t.string "payment_method" t.bigint "event_id" @@ -355,8 +389,8 @@ t.bigint "space_id", null: false t.date "date" t.string "duration" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false t.datetime "deleted_at", precision: nil t.index ["space_booking_id"], name: "index_space_reservations_on_space_booking_id" t.index ["space_id"], name: "index_space_reservations_on_space_id" @@ -365,26 +399,92 @@ create_table "spaces", force: :cascade do |t| t.string "name" t.text "description" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false t.string "code" t.datetime "deleted_at", precision: nil t.integer "position", default: 999 end + create_table "stay_item_dates", force: :cascade do |t| + t.string "booked_item_type", null: false + t.bigint "booked_item_id", null: false + t.date "booking_date", null: false + t.bigint "stay_id", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.boolean "direct_book", default: true + t.bigint "stay_item_id" + t.index ["booked_item_type", "booked_item_id"], name: "index_stay_item_dates_on_booked_item" + t.index ["stay_id"], name: "index_stay_item_dates_on_stay_id" + t.index ["stay_item_id"], name: "index_stay_item_dates_on_stay_item_id" + end + + create_table "stay_items", force: :cascade do |t| + t.bigint "stay_id", null: false + t.string "item_type", null: false + t.bigint "item_id", null: false + t.date "start_date", null: false + t.date "end_date", null: false + t.integer "quantity", default: 1 + t.integer "adults_count" + t.integer "children_count" + t.string "duration" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.integer "unit_price_cents", default: 0, null: false + t.string "unit_price_currency", default: "EUR", null: false + t.integer "babies_count" + t.integer "calculated_price_cents", default: 0, null: false + t.index ["item_type", "item_id"], name: "index_stay_items_on_item" + t.index ["stay_id"], name: "index_stay_items_on_stay_id" + end + + create_table "stays", force: :cascade do |t| + t.bigint "user_id", null: false + t.date "start_date" + t.date "end_date" + t.string "status" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.string "platform" + t.integer "adults" + t.integer "children" + t.integer "babies" + t.string "estimated_arrival" + t.string "departure_time" + t.string "token" + t.bigint "customer_id" + t.datetime "deleted_at", precision: nil + t.text "comments" + t.text "notes" + t.boolean "draft", default: true + t.string "payment_status" + t.string "invoice_status" + t.string "group_name" + t.text "public_notes" + t.integer "final_price_cents", default: 0, null: false + t.bigint "legacy_booking_id", comment: "Référence vers l'ancien booking migré" + t.bigint "legacy_space_booking_id" + t.index ["customer_id"], name: "index_stays_on_customer_id" + t.index ["legacy_booking_id"], name: "index_stays_on_legacy_booking_id" + t.index ["legacy_space_booking_id"], name: "index_stays_on_legacy_space_booking_id" + t.index ["user_id"], name: "index_stays_on_user_id" + end + create_table "stripe_events", force: :cascade do |t| t.string "webhook_id" t.string "event_type" t.string "object_id" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false end create_table "subscriptions", force: :cascade do |t| t.string "email" t.boolean "newsletter" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false end create_table "tasks", force: :cascade do |t| @@ -394,8 +494,8 @@ t.string "status" t.date "due_date" t.datetime "deleted_at", precision: nil - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false t.bigint "bundle_id", null: false t.index ["bundle_id"], name: "index_tasks_on_bundle_id" t.index ["project_id"], name: "index_tasks_on_project_id" @@ -405,15 +505,15 @@ t.string "name" t.text "description" t.datetime "deleted_at", precision: nil - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false end create_table "unavailabilities", force: :cascade do |t| t.date "date" t.bigint "lodging_id", null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false t.index ["lodging_id"], name: "index_unavailabilities_on_lodging_id" end @@ -421,10 +521,10 @@ t.string "email", default: "", null: false t.string "encrypted_password", default: "", null: false t.string "reset_password_token" - t.datetime "reset_password_sent_at" - t.datetime "remember_created_at" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false + t.datetime "reset_password_sent_at", precision: nil + t.datetime "remember_created_at", precision: nil + t.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false t.bigint "human_id" t.index ["email"], name: "index_users_on_email", unique: true t.index ["human_id"], name: "index_users_on_human_id" @@ -437,32 +537,47 @@ t.string "event", null: false t.string "whodunnit" t.text "object" - t.datetime "created_at" + t.datetime "created_at", precision: nil t.text "object_changes" t.index ["item_type", "item_id"], name: "index_versions_on_item_type_and_item_id" end - add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" - add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id" - add_foreign_key "bookings", "lodgings" - add_foreign_key "bundles", "projects" - add_foreign_key "bundles", "teams" - add_foreign_key "events", "event_categories" - add_foreign_key "experiences", "humans" - add_foreign_key "human_roles", "humans" - add_foreign_key "human_roles", "roles" - add_foreign_key "lodging_rooms", "lodgings" - add_foreign_key "lodging_rooms", "rooms" - add_foreign_key "payments", "bookings" - add_foreign_key "projects", "humans" - add_foreign_key "reservations", "bookings" - add_foreign_key "reservations", "rooms" - add_foreign_key "services", "humans" - add_foreign_key "space_bookings", "events" - add_foreign_key "space_reservations", "space_bookings" - add_foreign_key "space_reservations", "spaces" - add_foreign_key "tasks", "bundles" - add_foreign_key "tasks", "projects" - add_foreign_key "unavailabilities", "lodgings" - add_foreign_key "users", "humans" + create_table "watchman_notes", force: :cascade do |t| + t.date "date" + t.text "note" + t.datetime "created_at", precision: nil, null: false + t.datetime "updated_at", precision: nil, null: false + t.index ["date"], name: "index_watchman_notes_on_date" + end + + add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id", name: "active_storage_attachments_blob_id_fkey" + add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id", name: "active_storage_variant_records_blob_id_fkey" + add_foreign_key "beds", "rooms" + add_foreign_key "bookings", "lodgings", name: "bookings_lodging_id_fkey" + add_foreign_key "bundles", "projects", name: "bundles_project_id_fkey" + add_foreign_key "bundles", "teams", name: "bundles_team_id_fkey" + add_foreign_key "events", "event_categories", name: "events_event_category_id_fkey" + add_foreign_key "experiences", "humans", name: "experiences_human_id_fkey" + add_foreign_key "human_roles", "humans", name: "human_roles_human_id_fkey" + add_foreign_key "human_roles", "roles", name: "human_roles_role_id_fkey" + add_foreign_key "lodging_rooms", "lodgings", name: "lodging_rooms_lodging_id_fkey" + add_foreign_key "lodging_rooms", "rooms", name: "lodging_rooms_room_id_fkey" + add_foreign_key "payments", "bookings", name: "payments_booking_id_fkey" + add_foreign_key "payments", "stays" + add_foreign_key "projects", "humans", name: "projects_human_id_fkey" + add_foreign_key "reservations", "bookings", name: "reservations_booking_id_fkey" + add_foreign_key "reservations", "rooms", name: "reservations_room_id_fkey" + add_foreign_key "services", "humans", name: "services_human_id_fkey" + add_foreign_key "space_bookings", "events", name: "space_bookings_event_id_fkey" + add_foreign_key "space_reservations", "space_bookings", name: "space_reservations_space_booking_id_fkey" + add_foreign_key "space_reservations", "spaces", name: "space_reservations_space_id_fkey" + add_foreign_key "stay_item_dates", "stay_items" + add_foreign_key "stay_item_dates", "stays" + add_foreign_key "stay_items", "stays" + add_foreign_key "stays", "customers" + add_foreign_key "stays", "users" + add_foreign_key "tasks", "bundles", name: "tasks_bundle_id_fkey" + add_foreign_key "tasks", "projects", name: "tasks_project_id_fkey" + add_foreign_key "unavailabilities", "lodgings", name: "unavailabilities_lodging_id_fkey" + add_foreign_key "users", "humans", name: "users_human_id_fkey" end diff --git a/db/seeds.rb b/db/seeds.rb index fcc61c8..fa0a799 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -6,75 +6,422 @@ # movies = Movie.create([{ name: "Star Wars" }, { name: "Lord of the Rings" }]) # Character.create(name: "Luke", movie: movies.first) -lodging_8 = Lodging.create(name: "La Chevêche", summary: "4 à 8 personnes", price_night: 240) -lodging_16 = Lodging.create(name: "La Hulotte", summary: "9 à 16 personnes", price_night: 480) -lodging_25 = Lodging.create(name: "Le Grand-Duc", summary: "17 à 25 personnes", price_night: 750, party_hall_availability: true) - -room_romarin = Room.create(name: "Romarin", level: 0, code: "ROM", description: "2 lits simples adaptables en lit double + lit superposé") -room_balsamine = Room.create(name: "Balsamine", level: 0, code: "BAL", description: "2 lits simples adaptables en lit double + lit superposé") -room_lavande = Room.create(name: "Lavande", level: 1, code: "LAV", description: "2 lits simples adaptables en lit double") -room_melisse = Room.create(name: "Mélisse", level: 1, code: "MEL", description: "3 lits simples") -room_capucine = Room.create(name: "Capucine", level: 1, code: "CAP", description: "3 lits simples") -room_sarriette = Room.create(name: "Sarriette", level: 2, code: "SAR", description: "2 lits simples adaptables en lit double + lit simple") -room_origan = Room.create(name: "Origan", level: 2, code: "ORI", description: "2 lits simples adaptables en lit double + lit superposé") -room_laurier = Room.create(name: "Laurier (mezzanine)", level: 2, code: "MEZ", description: "2 lits simples") - -LodgingRoom.create(lodging: lodging_8, room: room_romarin) -LodgingRoom.create(lodging: lodging_8, room: room_balsamine) - -LodgingRoom.create(lodging: lodging_16, room: room_lavande) -LodgingRoom.create(lodging: lodging_16, room: room_melisse) -LodgingRoom.create(lodging: lodging_16, room: room_capucine) -LodgingRoom.create(lodging: lodging_16, room: room_sarriette) -LodgingRoom.create(lodging: lodging_16, room: room_origan) -LodgingRoom.create(lodging: lodging_16, room: room_laurier) - -LodgingRoom.create(lodging: lodging_25, room: room_romarin) -LodgingRoom.create(lodging: lodging_25, room: room_balsamine) -LodgingRoom.create(lodging: lodging_25, room: room_lavande) -LodgingRoom.create(lodging: lodging_25, room: room_melisse) -LodgingRoom.create(lodging: lodging_25, room: room_capucine) -LodgingRoom.create(lodging: lodging_25, room: room_sarriette) -LodgingRoom.create(lodging: lodging_25, room: room_origan) -LodgingRoom.create(lodging: lodging_25, room: room_laurier) - -Space.create(name: "Tilleul", code: "TIL", description: "1er étage, 140 m2") -Space.create(name: "Saule", code: "SAU", description: "1er étage, 45 m2") -Space.create(name: "Les 2 salles", code: "T+S", description: "1er étage, 185 m2") -Space.create(name: "Chêne", code: "CHE", description: "2ème étage, 45 m2") -Space.create(name: "Cuisine professionnelle", code: "CUI") -Space.create(name: "Chambre froide", code: "CHA") - -jeanclaude = Human.create(name: "Jean-Claude", email: "jeanclaude@claudy.test") -User.create(email: "jeanclaude@claudy.test", password: "secret", human: jeanclaude) - -miranda = Human.create(name: "Miranda", email: "miranda@claudy.test") -User.create(email: "miranda@claudy.test", password: "secret", human: miranda) - -Team.create(name: "Pole Technique") -Team.create(name: "Pole Accueil") -Team.create(name: "Pole Espaces verts") - -project = Project.create(name: "Poulailler mobile", due_date: Date.today + 4.months, human: jeanclaude) - -Task.create( - name: "Faire les plans détaillés du poulailler", - project: project, - status: Task::STATUS_IN_PROGRESS, - due_date: Date.today + 1.month, - humans: [jeanclaude] -) -Task.create( - name: "Réunir les matériaux nécessaires à la construction", - project: project, - status: Task::STATUS_OPEN, - due_date: Date.today + 2.months, - humans: [jeanclaude, miranda] -) -Task.create( - name: "Construire le poulailler", - project: project, - status: Task::STATUS_OPEN, - due_date: Date.today + 4.month, - humans: [jeanclaude, miranda] -) +#lodging_8 = Lodging.create(name: "La Chevêche", summary: "4 à 8 personnes", price_night: 240) +lodging_8 = Lodging.find_or_create_by(name: 'La Chevêche') do |lodging| + lodging.name = 'La Chevêche' + lodging.summary = '4 à 8 personnes' + lodging.price_night = '240' +end +lodging_16 = Lodging.find_or_create_by(name: 'La Hulotte') do |lodging| + lodging.name = 'La Hulotte' + lodging.summary = '9 à 16 personnes' + lodging.price_night = '480' +end +lodging_25 = Lodging.find_or_create_by(name: 'Le Grand-Duc') do |lodging| + lodging.name = 'Le Grand-Duc' + lodging.summary = '17 à 25 personnes' + lodging.price_night = '750' + lodging.party_hall_availability = true +end +#lodging_16 = Lodging.create(name: "La Hulotte", summary: "9 à 16 personnes", price_night: 480) +#lodging_25 = Lodging.create(name: "Le Grand-Duc", summary: "17 à 25 personnes", price_night: 750, party_hall_availability: true) + +room_romarin = Room.find_or_create_by(code: 'BAL') do |room| + room.name = "Romarin" + room.level = 0 + room.code = "ROM" + room.description = "2 lits simples adaptables en lit double + lit superposé" +end + +room_balsamine = Room.find_or_create_by(code: 'BAL') do |room| + room.name = 'Balsamine' + room.level = 0 + room.code = 'BAL' + room.description = "2 lits simples adaptables en lit double + lit superposé" +end + +room_lavande = Room.find_or_create_by(code: 'LAV') do |room| + room.name = 'Lavande' + room.level = 1 + room.code = 'LAV' + room.description = "2 lits simples adaptables en lit double" +end + +room_melisse = Room.find_or_create_by(code: 'MEL') do |room| + room.name = 'Melisse' + room.level = 1 + room.code = 'MEL' + room.description = "3 lits simples" +end + +room_capucine = Room.find_or_create_by(code: 'CAP') do |room| + room.name = 'Capucine' + room.level = 1 + room.code = 'CAP' + room.description = "3 lits simples" +end + +room_sarriette = Room.find_or_create_by(code: 'SAR') do |room| + room.name = 'Sarriette' + room.level = 2 + room.code = 'SAR' + room.description = "2 lits simples adaptables en lit double + lit simple" +end + +room_origan = Room.find_or_create_by(code: 'ORI') do |room| + room.name = 'Origan' + room.level = 2 + room.code = 'ORI' + room.description = "2 lits simples adaptables en lit double + lit superposé" +end + +room_laurier = Room.find_or_create_by(code: 'MEZ') do |room| + room.name = 'Laurier (mezzanine)' + room.level = 2 + room.code = 'MEZ' + room.description = "2 lits simples" +end +#room_romarin = Room.create(name: "Romarin", level: 0, code: "ROM", description: "lit double + lit superposé") +#room_balsamine = Room.create(name: "Balsamine", level: 0, code: "BAL", description: "lit double + lit superposé") +#room_lavande = Room.create(name: "Lavande", level: 1, code: "LAV", description: "2 lits simples") +#room_melisse = Room.create(name: "Mélisse", level: 1, code: "MEL", description: "3 lits simples") +#room_capucine = Room.create(name: "Capucine", level: 1, code: "CAP", description: "3 lits simples") +#room_sarriette = Room.create(name: "Sarriette", level: 2, code: "SAR", description: "lit double + lit simple") +#room_origan = Room.create(name: "Origan", level: 2, code: "ORI", description: "lit double + lit superposé") +#room_laurier = Room.create(name: "Laurier (mezzanine)", level: 2, code: "MEZ", description: "2 lits simples") + + +Bed.find_or_create_by(name: 'Lit 1') do |bed| + bed.name = "Lit 1" + bed.description = "double" + bed.price_cents = 500 + bed.room = room_romarin +end +Bed.find_or_create_by(name: 'Lit 2') do |bed| + bed.name = "Lit 2" + bed.description = "superposé" + bed.price_cents = 500 + bed.room = room_romarin +end + +Bed.find_or_create_by(name: 'Lit 3') do |bed| + bed.name = "Lit 3" + bed.description = "double" + bed.price_cents = 500 + bed.room = room_balsamine +end +Bed.find_or_create_by(name: 'Lit 4') do |bed| + bed.name = "Lit 4" + bed.description = "superposé" + bed.price_cents = 500 + bed.room = room_balsamine +end + +Bed.find_or_create_by(name: 'Lit 5') do |bed| + bed.name = "Lit 5" + bed.description = "simple" + bed.price_cents = 500 + bed.room = room_lavande +end +Bed.find_or_create_by(name: 'Lit 6') do |bed| + bed.name = "Lit 6" + bed.description = "simple" + bed.price_cents = 500 + bed.room = room_lavande +end + +Bed.find_or_create_by(name: 'Lit 7') do |bed| + bed.name = "Lit 7" + bed.description = "simple" + bed.price_cents = 500 + bed.room = room_melisse +end +Bed.find_or_create_by(name: 'Lit 8') do |bed| + bed.name = "Lit 8" + bed.description = "simple" + bed.price_cents = 500 + bed.room = room_melisse +end +Bed.find_or_create_by(name: 'Lit 9') do |bed| + bed.name = "Lit 9" + bed.description = "simple" + bed.price_cents = 500 + bed.room = room_melisse +end +Bed.find_or_create_by(name: 'Lit 10') do |bed| + bed.name = "Lit 10" + bed.description = "simple" + bed.price_cents = 500 + bed.room = room_capucine +end +Bed.find_or_create_by(name: 'Lit 11') do |bed| + bed.name = "Lit 11" + bed.description = "simple" + bed.price_cents = 500 + bed.room = room_capucine +end +Bed.find_or_create_by(name: 'Lit 12') do |bed| + bed.name = "Lit 12" + bed.description = "simple" + bed.price_cents = 500 + bed.room = room_capucine +end + +Bed.find_or_create_by(name: 'Lit 13') do |bed| + bed.name = "Lit 13" + bed.description = "simple" + bed.price_cents = 500 + bed.room = room_sarriette +end +Bed.find_or_create_by(name: 'Lit 14') do |bed| + bed.name = "Lit 14" + bed.description = "double" + bed.price_cents = 500 + bed.room = room_sarriette +end + +Bed.find_or_create_by(name: 'Lit 15') do |bed| + bed.name = "Lit 15" + bed.description = "double" + bed.price_cents = 500 + bed.room = room_origan +end +Bed.find_or_create_by(name: 'Lit 16') do |bed| + bed.name = "Lit 16" + bed.description = "superposé" + bed.price_cents = 500 + bed.room = room_origan +end + +Bed.find_or_create_by(name: 'Lit 17') do |bed| + bed.name = "Lit 17" + bed.description = "simple" + bed.price_cents = 500 + bed.room = room_laurier +end +Bed.find_or_create_by(name: 'Lit 18') do |bed| + bed.name = "Lit 18" + bed.description = "simple" + bed.price_cents = 500 + bed.room = room_laurier +end + + + + +LodgingRoom.find_or_create_by(lodging: lodging_8, room: room_romarin) do |lr| + lr.room = room_romarin + lr.lodging = lodging_8 +end +LodgingRoom.find_or_create_by(lodging: lodging_8,room: room_balsamine) do |lr| + lr.room = room_balsamine + lr.lodging = lodging_8 +end + +#LodgingRoom.create(lodging: lodging_8, room: room_romarin) +#LodgingRoom.create(lodging: lodging_8, room: room_balsamine) + +LodgingRoom.find_or_create_by(lodging: lodging_16, room: room_lavande) do |lr| + lr.room = room_lavande + lr.lodging = lodging_16 +end +LodgingRoom.find_or_create_by(lodging: lodging_16,room: room_melisse) do |lr| + lr.room = room_melisse + lr.lodging = lodging_16 +end +LodgingRoom.find_or_create_by(lodging: lodging_16, room: room_capucine) do |lr| + lr.room = room_capucine + lr.lodging = lodging_16 +end +LodgingRoom.find_or_create_by(lodging: lodging_16,room: room_sarriette) do |lr| + lr.room = room_sarriette + lr.lodging = lodging_16 +end +LodgingRoom.find_or_create_by(lodging: lodging_16, room: room_origan) do |lr| + lr.room = room_origan + lr.lodging = lodging_16 +end + +#LodgingRoom.find_or_create_by(lodging: lodging_16,room: room_laurier) do |lr| +# lr.room = room_laurier +# lr.lodging = lodging_16 +#end + +#LodgingRoom.create(lodging: lodging_16, room: room_lavande) +#LodgingRoom.create(lodging: lodging_16, room: room_melisse) +#LodgingRoom.create(lodging: lodging_16, room: room_capucine) +#LodgingRoom.create(lodging: lodging_16, room: room_sarriette) +#LodgingRoom.create(lodging: lodging_16, room: room_origan) +#LodgingRoom.create(lodging: lodging_16, room: room_laurier) + +LodgingRoom.find_or_create_by(lodging: lodging_25,room: room_romarin) do |lr| + lr.room = room_romarin + lr.lodging = lodging_25 +end +LodgingRoom.find_or_create_by(lodging: lodging_25,room: room_balsamine) do |lr| + lr.room = room_balsamine + lr.lodging = lodging_25 +end +LodgingRoom.find_or_create_by(lodging: lodging_25,room: room_lavande) do |lr| + lr.room = room_lavande + lr.lodging = lodging_25 +end +LodgingRoom.find_or_create_by(lodging: lodging_25,room: room_melisse) do |lr| + lr.room = room_melisse + lr.lodging = lodging_25 +end +LodgingRoom.find_or_create_by(lodging: lodging_25,room: room_capucine) do |lr| + lr.room = room_capucine + lr.lodging = lodging_25 +end +LodgingRoom.find_or_create_by(lodging: lodging_25,room: room_sarriette) do |lr| + lr.room = room_sarriette + lr.lodging = lodging_25 +end +LodgingRoom.find_or_create_by(lodging: lodging_25,room: room_origan) do |lr| + lr.room = room_origan + lr.lodging = lodging_25 +end +#LodgingRoom.find_or_create_by(lodging: lodging_25,room: room_laurier) do |lr| +# lr.room = room_laurier +# lr.lodging = lodging_25 +#end +#LodgingRoom.create(lodging: lodging_25, room: room_romarin) +#LodgingRoom.create(lodging: lodging_25, room: room_balsamine) +#LodgingRoom.create(lodging: lodging_25, room: room_lavande) +#LodgingRoom.create(lodging: lodging_25, room: room_melisse) +#LodgingRoom.create(lodging: lodging_25, room: room_capucine) +#LodgingRoom.create(lodging: lodging_25, room: room_sarriette) +#LodgingRoom.create(lodging: lodging_25, room: room_origan) +#LodgingRoom.create(lodging: lodging_25, room: room_laurier) + + +Space.find_or_create_by(code: 'TIL') do |space| + space.name = 'Tilleul' + space.code = 'TIL' + space.description = '1er étage, 140 m2' +end +Space.find_or_create_by(code: 'SAU') do |space| + space.name = 'Saule' + space.code = 'SAU' + space.description = '1er étage, 45 m2' +end +Space.find_or_create_by(code: 'T+S') do |space| + space.name = 'Les 2 salles' + space.code = 'T+S' + space.description = '1er étage, 185 m2' +end +Space.find_or_create_by(code: 'CHE') do |space| + space.name = 'Chêne' + space.code = 'CHE' + space.description = '2ème étage, 45 m2' +end +Space.find_or_create_by(code: 'CUI') do |space| + space.name = 'Cuisine professionnelle' + space.code = 'CUI' +end +Space.find_or_create_by(code: 'CHA') do |space| + space.name = 'Chambre froide' + space.code = 'CHA' +end +#Space.create(name: "Tilleul", code: "TIL", description: "1er étage, 140 m2") +#Space.create(name: "Saule", code: "SAU", description: "1er étage, 45 m2") +#Space.create(name: "Les 2 salles", code: "T+S", description: "1er étage, 185 m2") +#Space.create(name: "Chêne", code: "CHE", description: "2ème étage, 45 m2") +#Space.create(name: "Cuisine professionnelle", code: "CUI") +#Space.create(name: "Chambre froide", code: "CHA") + + +#jeanclaude = Human.find_or_create_by(email: "jeanclaude@claudy.test") do |human| +# human.name = "Jean-Claude" +# human.email = "jeanclaude@claudy.test" +#end +#User.find_or_create_by(email: "jeanclaude@claudy.test") do |user| +# user.password = "secret" +# user.human = jeanclaude +# user.email = "jeanclaude@claudy.test" +#end + +#miranda = Human.find_or_create_by(email: "jmiranda@claudy.test") do |human| +# human.name = "Miranda" +# human.email = "miranda@claudy.test" +#end +#User.find_or_create_by(email: "jeanclaude@claudy.test") do |user| +# user.password = "secret" +# user.human = miranda +# user.email = "miranda@claudy.test" +#end + +#jeanclaude = Human.create(name: "Jean-Claude", email: "jeanclaude@claudy.test") +#User.create(email: "jeanclaude@claudy.test", password: "secret", human: jeanclaude) + +#miranda = Human.create(name: "Miranda", email: "miranda@claudy.test") +#User.create(email: "miranda@claudy.test", password: "secret", human: miranda) + +#Team.find_or_create_by(name: "Pole Technique") do |team| +# team.name = "Pole Technique" +#end +#Team.find_or_create_by(name: "Pole Accueil") do |team| +# team.name = "Pole Accueil" +#end +#Team.find_or_create_by(name: "Pole Espaces verts") do |team| +# team.name = "Pole Espaces verts" +#end +#Team.create(name: "Pole Technique") +#Team.create(name: "Pole Accueil") +#Team.create(name: "Pole Espaces verts") + + +#project = Project.find_or_create_by(name: "Poulailler mobile") do |project| +# project.name = "" +# project.human = jeanclaude +# project.due_date = Date.today + 4.months +#end + +#project = Project.create(name: "Poulailler mobile", due_date: Date.today + 4.months, human: jeanclaude) + +#Task.find_or_create_by(name: "Faire les plans détaillés du poulailler") do |task| +# task.name = "Faire les plans détaillés du poulailler" +# task.project = project +# task.status = Task::STATUS_IN_PROGRESS +# task.due_date = Date.today + 1.month +# task.humans = [jeanclaude] +#end + +#Task.find_or_create_by(name: "Réunir les matériaux nécessaires à la construction") do |task| +# task.name = "Réunir les matériaux nécessaires à la construction" +# task.project = project +# task.status = Task::STATUS_OPEN +# task.due_date = Date.today + 2.month +# task.humans = [jeanclaude, miranda] +#end + +#Task.find_or_create_by(name: "Construire le poulailler") do |task| +# task.name = "Construire le poulailler" +# task.project = project +# task.status = Task::STATUS_OPEN +# task.due_date = Date.today + 4.month +# task.humans = [jeanclaude, miranda] +#end + +#Task.create( +# name: "Faire les plans détaillés du poulailler", +# project: project, +# status: Task::STATUS_IN_PROGRESS, +# due_date: Date.today + 1.month, +# humans: [jeanclaude] +#) +#Task.create( +# name: "Réunir les matériaux nécessaires à la construction", +# project: project, +# status: Task::STATUS_OPEN, +# due_date: Date.today + 2.months, +# humans: [jeanclaude, miranda] +#) +#Task.create( +# name: "Construire le poulailler", +# project: project, +# status: Task::STATUS_OPEN, +# due_date: Date.today + 4.month, +# humans: [jeanclaude, miranda] +#) diff --git a/lib/tasks/migrate_bookings_to_stays.rake b/lib/tasks/migrate_bookings_to_stays.rake new file mode 100644 index 0000000..f21a684 --- /dev/null +++ b/lib/tasks/migrate_bookings_to_stays.rake @@ -0,0 +1,257 @@ +namespace :migration do + desc "Migrer les bookings vers les stays et customers" + task migrate_bookings_to_stays: :environment do + puts "🚀 Début de la migration des bookings vers les stays..." + + # Compter les bookings à migrer + total_bookings = Booking.unscoped.count + puts "📊 #{total_bookings} bookings à migrer" + + # Garder trace des IDs des stays créés pour rollback en cas d'erreur + created_stay_ids = [] + created_customer_ids = [] + + # Transaction pour s'assurer de la cohérence + ActiveRecord::Base.transaction do + begin + Booking.unscoped.find_each.with_index do |booking, index| + puts "⏳ Migration du booking #{index + 1}/#{total_bookings} (ID: #{booking.id})" + + # 1. Créer ou récupérer le customer + customer = nil + if booking.email.present? + # Essayer de récupérer un customer existant basé sur l'email + customer = Customer.find_by(email: booking.email) + if customer.nil? + customer = Customer.create!( + email: booking.email, + firstname: booking.firstname, + lastname: booking.lastname, + phone: booking.phone, + created_at: booking.created_at, + updated_at: booking.updated_at + ) + created_customer_ids << customer.id + puts " ✅ Nouveau customer créé avec email (ID: #{customer.id})" + else + puts " 🔄 Customer existant récupéré (ID: #{customer.id})" + end + else + # Créer un customer même sans email + customer = Customer.create!( + email: booking.email, # sera nil ou "" + firstname: booking.firstname, + lastname: booking.lastname, + phone: booking.phone, + created_at: booking.created_at, + updated_at: booking.updated_at + ) + created_customer_ids << customer.id + puts " ✅ Nouveau customer créé sans email (ID: #{customer.id})" + end + + # 2. Créer le stay + stay = Stay.new( + user_id: 1, # Utilisateur par défaut pour la migration + legacy_booking_id: booking.id, # Référence vers l'ancien booking + customer: customer, + start_date: booking.from_date, + end_date: booking.to_date, + status: booking.status, + adults: booking.adults, + children: booking.children, + babies: booking.babies, + estimated_arrival: booking.estimated_arrival, + departure_time: booking.departure_time, + token: booking.token, + platform: booking.platform, + notes: booking.notes, + comments: booking.comments, + draft: false, # Les bookings existants ne sont pas des brouillons + invoice_status: booking.invoice_status, + group_name: booking.group_name, + public_notes: booking.public_notes, + deleted_at: booking.deleted_at, + final_price_cents: booking.price_cents || 0 + ) + + # Définir les timestamps manuellement + stay.created_at = booking.created_at + stay.updated_at = booking.updated_at + + stay.save! + created_stay_ids << stay.id + puts " ✅ Stay créé (ID: #{stay.id})" + + # 3. Créer les StayItems + if booking.lodging_id.present? + # 3a. Créer le StayItem pour le lodging si lodging_id existe + stay_item = StayItem.create!( + stay: stay, + item_type: StayItem::LODGING, + item_id: booking.lodging_id, + start_date: booking.from_date, + end_date: booking.to_date, + quantity: 1, + adults_count: booking.adults, + children_count: booking.children, + babies_count: booking.babies + ) + puts " ✅ StayItem pour lodging créé (ID: #{stay_item.id})" + elsif booking.reservations.any? + # 3b. Créer les StayItems pour les rooms via les reservations (seulement si pas de lodging) + booking.reservations.group_by(&:room_id).each do |room_id, reservations| + stay_item = StayItem.create!( + stay: stay, + item_type: StayItem::ROOM, + item_id: room_id, + start_date: booking.from_date, + end_date: booking.to_date, + quantity: 1, + adults_count: booking.adults, + children_count: booking.children, + babies_count: booking.babies + ) + puts " ✅ StayItem pour room #{room_id} créé (ID: #{stay_item.id}) - #{reservations.length} nuits" + end + end + + # 4. Mettre à jour les paiements existants pour rattacher le stay + Payment.where(booking_id: booking.id).find_each do |payment| + payment.update!(stay_id: stay.id) + puts " 🔗 Paiement #{payment.id} mis à jour avec stay_id=#{stay.id}" + end + + # 5. Mettre à jour le payment_status du stay + stay.set_payment_status + + puts " 🎯 Booking #{booking.id} migré avec succès vers Stay #{stay.id}" + end + + puts "🎉 Migration terminée avec succès !" + puts "📈 #{created_stay_ids.length} stays créés" + puts "👥 #{created_customer_ids.length} nouveaux customers créés" + + rescue => e + puts "❌ Erreur durant la migration: #{e.message}" + puts "🔄 Rollback en cours..." + + # Supprimer tous les stays créés (cascade supprimera les stay_items et payments liés) + Stay.where(id: created_stay_ids).destroy_all + puts "🗑️ #{created_stay_ids.length} stays supprimés" + + # Supprimer les nouveaux customers créés (seulement ceux sans stays restants) + created_customer_ids.each do |customer_id| + customer = Customer.find_by(id: customer_id) + if customer && customer.stays.empty? + customer.destroy + puts "🗑️ Customer #{customer_id} supprimé" + end + end + + puts "💥 Migration annulée - rollback effectué" + raise e # Re-lever l'erreur pour faire échouer la transaction + end + end + end + + desc "Vérifier l'état avant la migration" + task check_migration_status: :environment do + puts "🔍 Vérification de l'état avant migration..." + + bookings_count = Booking.unscoped.count + stays_count = Stay.unscoped.count + customers_count = Customer.count + + puts "📊 État actuel :" + puts " - Bookings : #{bookings_count}" + puts " - Stays : #{stays_count}" + puts " - Customers : #{customers_count}" + + if stays_count > 0 + puts "⚠️ ATTENTION : Il y a déjà #{stays_count} stays en base !" + puts " Assurez-vous que c'est voulu avant de lancer la migration." + end + + # Vérifier les bookings avec des données manquantes + bookings_without_email = Booking.unscoped.where(email: [nil, ""]).count + bookings_without_dates = Booking.unscoped.where("from_date IS NULL OR to_date IS NULL").count + bookings_with_lodging = Booking.unscoped.where.not(lodging_id: nil).count + bookings_with_reservations = Booking.unscoped.joins(:reservations).distinct.count + + puts "\n🔎 Analyse des données :" + puts " - Bookings sans email : #{bookings_without_email}" + puts " - Bookings sans dates : #{bookings_without_dates}" + puts " - Bookings avec lodging : #{bookings_with_lodging}" + puts " - Bookings avec réservations de rooms : #{bookings_with_reservations}" + + if bookings_without_email > 0 + puts " ⚠️ Ces bookings seront migrés avec un customer ayant un email vide" + end + + if bookings_without_dates > 0 + puts " ❌ Ces bookings causeront des erreurs - il faut les corriger d'abord" + end + + unique_emails = Booking.unscoped.where.not(email: [nil, ""]).distinct.count(:email) + puts " - Emails uniques : #{unique_emails} (nombre de customers potentiels)" + + total_reservations = Reservation.count + puts " - Total réservations : #{total_reservations}" + end + + desc "Nettoyer les stays et customers créés par la migration (DANGER)" + task clean_migrated_data: :environment do + puts "⚠️ ATTENTION : Cette tâche va supprimer TOUS les stays et customers !" + puts " Appuyez sur Entrée pour continuer ou Ctrl+C pour annuler..." + STDIN.gets + + stays_count = Stay.unscoped.count + customers_count = Customer.count + + puts "🗑️ Suppression en cours..." + + # Supprimer tous les stays (cascade sur stay_items et payments) + Stay.unscoped.destroy_all + puts " ✅ #{stays_count} stays supprimés" + + # Supprimer tous les customers + Customer.destroy_all + puts " ✅ #{customers_count} customers supprimés" + + puts "🧹 Nettoyage terminé !" + end + + desc "Afficher un rapport de migration" + task migration_report: :environment do + puts "📋 Rapport de migration" + puts "=" * 50 + + bookings_count = Booking.unscoped.count + stays_count = Stay.unscoped.count + customers_count = Customer.count + payments_count = Payment.count + stay_items_count = StayItem.count + + puts "📊 Données actuelles :" + puts " - Bookings : #{bookings_count}" + puts " - Stays : #{stays_count}" + puts " - Customers : #{customers_count}" + puts " - Payments : #{payments_count}" + puts " - StayItems : #{stay_items_count}" + + if stays_count > 0 + puts "\n🎯 Détails des stays :" + puts " - Avec customer : #{Stay.joins(:customer).count}" + puts " - Sans customer : #{Stay.where(customer: nil).count}" + puts " - Avec paiements : #{Stay.joins(:payments).distinct.count}" + puts " - Avec stay_items : #{Stay.joins(:stay_items).distinct.count}" + + status_breakdown = Stay.group(:status).count + puts "\n📈 Répartition par status :" + status_breakdown.each do |status, count| + puts " - #{status || 'nil'} : #{count}" + end + end + end +end \ No newline at end of file diff --git a/lib/tasks/migrate_space_bookings_to_stays.rake b/lib/tasks/migrate_space_bookings_to_stays.rake new file mode 100644 index 0000000..0445803 --- /dev/null +++ b/lib/tasks/migrate_space_bookings_to_stays.rake @@ -0,0 +1,106 @@ +namespace :db do + desc "Migration des SpaceBookings vers Stay" + task migrate_space_bookings_to_stays: :environment do + puts "Début de la migration des SpaceBookings vers Stay..." + total = SpaceBooking.count + migrated = 0 + + SpaceBooking.find_each do |space_booking| + ActiveRecord::Base.transaction do + puts "\nMigration du SpaceBooking ##{space_booking.id} (#{space_booking.firstname} #{space_booking.lastname}, email: #{space_booking.email})..." + # 1. Client + customer = Customer.find_by(email: space_booking.email) + if customer && customer.email.present? + puts " - Client existant trouvé (id: #{customer.id})" + else + customer = Customer.create!( + firstname: space_booking.firstname, + lastname: space_booking.lastname, + email: space_booking.email, + phone: space_booking.phone + ) + puts " - Nouveau client créé (id: #{customer.id})" + end + + # 2. Stay + stay = Stay.new( + user_id: User.first.id, # ou adapter selon besoin + customer: customer, + group_name: space_booking.group_name, + status: space_booking.status, + payment_status: space_booking.payment_status, + invoice_status: space_booking.invoice_status, + notes: space_booking.notes, + token: space_booking.token, + public_notes: space_booking.public_notes, + departure_time: space_booking.departure_time, + start_date: space_booking.from_date, + end_date: space_booking.to_date, + created_at: space_booking.created_at, + updated_at: space_booking.updated_at, + final_price_cents: space_booking.price_cents, + adults: space_booking.persons.to_i, + estimated_arrival: space_booking.arrival_time, + deleted_at: space_booking.deleted_at, + legacy_space_booking_id: space_booking.id, + draft: false + ) + + # 3. Notes : caution/acompte + notes_lines = [] + notes_lines << stay.notes if stay.notes.present? + if space_booking.deposit_amount_cents.present? && space_booking.deposit_amount_cents > 0.0 + notes_lines << "Montant de la caution : #{space_booking.deposit_amount_cents / 100.0}€" + puts " - Caution détectée : #{space_booking.deposit_amount_cents / 100.0}€" + end + if space_booking.advance_amount_cents.present? && space_booking.advance_amount_cents > 0.0 + notes_lines << "Montant de l'acompte : #{space_booking.advance_amount_cents / 100.0}€" + puts " - Acompte détecté : #{space_booking.advance_amount_cents / 100.0}€" + end + stay.notes = notes_lines.reject(&:blank?).join("\n") + + stay.save! + puts " - Stay créé (id: #{stay.id})" + + # 4. Migration des espaces réservés (SpaceReservations) + count_items = 0 + space_booking.space_reservations.each do |space_reservation| + next if space_reservation.deleted_at.present? + StayItem.create!( + stay: stay, + item_type: 'Space', + item_id: space_reservation.space_id, + start_date: space_reservation.date, + end_date: space_reservation.date, + duration: space_reservation.duration, + quantity: 1, + calculated_price_cents: 0 + ) + count_items += 1 + end + puts " - #{count_items} StayItem(s) (Space) créés pour ce séjour." + + # 5. Paiement + if space_booking.payment_method.present? && space_booking.paid_amount_cents.to_f > 0.0 + payment = Payment.create!( + stay: stay, + payment_method: space_booking.payment_method, + status: space_booking.payment_status, + amount_cents: space_booking.paid_amount_cents || 0 + ) + payment.update_columns(created_at: space_booking.to_date.to_datetime, updated_at: space_booking.to_date.to_datetime) + puts " - Paiement créé (méthode: #{space_booking.payment_method}, montant: #{space_booking.paid_amount_cents} cts)" + else + puts " - Aucun paiement à migrer." + end + + # 6. Forcer created_at/updated_at si besoin + stay.update_columns(created_at: space_booking.created_at, updated_at: space_booking.updated_at) + migrated += 1 + puts " > Migration du SpaceBooking ##{space_booking.id} terminée. (#{migrated}/#{total})" + end + end + + puts "\nMigration terminée : #{migrated} SpaceBookings migrés sur #{total}." + end +end \ No newline at end of file diff --git a/package.json b/package.json index fa08664..bf2a037 100644 --- a/package.json +++ b/package.json @@ -16,8 +16,10 @@ "el-transition": "^0.0.7", "flowbite": "^1.5.5", "foundation-sites": "^6.7.5", + "jquery": "^3.7.1", "moment": "latest", "postcss": "^8.4.19", + "select2": "^4.1.0-rc.0", "tailwindcss": "^3.2.4", "tailwindcss-stimulus-components": "^3.0.4", "trix": "^2.1.3", diff --git a/spec/models/human_spec.rb b/spec/models/human_spec.rb index a3b1486..a54f765 100644 --- a/spec/models/human_spec.rb +++ b/spec/models/human_spec.rb @@ -11,6 +11,7 @@ # deleted_at :datetime # created_at :datetime not null # updated_at :datetime not null +# status :string default("active") # require 'rails_helper' diff --git a/spec/models/payment_spec.rb b/spec/models/payment_spec.rb index d952952..137b8f4 100644 --- a/spec/models/payment_spec.rb +++ b/spec/models/payment_spec.rb @@ -2,7 +2,7 @@ # # Table name: payments # -# booking_id :bigint not null +# booking_id :bigint # payment_method :string # status :string # deleted_at :datetime @@ -12,6 +12,8 @@ # stripe_checkout_session_id :string # stripe_payment_intent_id :string # id :uuid not null, primary key +# stay_id :bigint +# payment_request_id :bigint # require 'rails_helper' diff --git a/spec/models/space_spec.rb b/spec/models/space_spec.rb index 77c6b05..68b8dd6 100644 --- a/spec/models/space_spec.rb +++ b/spec/models/space_spec.rb @@ -9,7 +9,7 @@ # updated_at :datetime not null # code :string # deleted_at :datetime -# position :integer default(0) +# position :integer default(999) # require 'rails_helper' diff --git a/tailwind.config.js b/tailwind.config.js index 350a5e0..ca942fe 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -20,6 +20,9 @@ module.exports = { ], theme: { extend: { + colors: { + '4s-main': '#024442', + }, fontFamily: { sans: ['Inter var', ...defaultTheme.fontFamily.sans], caveat: ['Caveat', 'Inter var', ...defaultTheme.fontFamily.sans] diff --git a/test/fixtures/lodgings.yml b/test/fixtures/lodgings.yml index 2f83cf0..77765cb 100644 --- a/test/fixtures/lodgings.yml +++ b/test/fixtures/lodgings.yml @@ -13,6 +13,7 @@ # weekend_discount_cents :integer default(0), not null # deleted_at :datetime # show_on_reports :boolean default(TRUE) +# available_for_bookings :boolean # one: diff --git a/test/fixtures/rooms.yml b/test/fixtures/rooms.yml index 44bdfe9..ff4c9cd 100644 --- a/test/fixtures/rooms.yml +++ b/test/fixtures/rooms.yml @@ -2,14 +2,15 @@ # # Table name: rooms # -# id :bigint not null, primary key -# name :string -# description :text -# created_at :datetime not null -# updated_at :datetime not null -# level :integer -# code :string -# deleted_at :datetime +# id :bigint not null, primary key +# name :string +# description :text +# created_at :datetime not null +# updated_at :datetime not null +# level :integer +# code :string +# deleted_at :datetime +# price_night_cents :integer default(0), not null # one: diff --git a/test/models/lodging_test.rb b/test/models/lodging_test.rb index afce4df..3de789f 100644 --- a/test/models/lodging_test.rb +++ b/test/models/lodging_test.rb @@ -13,6 +13,7 @@ # weekend_discount_cents :integer default(0), not null # deleted_at :datetime # show_on_reports :boolean default(TRUE) +# available_for_bookings :boolean # require "test_helper" diff --git a/test/models/room_test.rb b/test/models/room_test.rb index cc2dacc..3d364a4 100644 --- a/test/models/room_test.rb +++ b/test/models/room_test.rb @@ -2,14 +2,15 @@ # # Table name: rooms # -# id :bigint not null, primary key -# name :string -# description :text -# created_at :datetime not null -# updated_at :datetime not null -# level :integer -# code :string -# deleted_at :datetime +# id :bigint not null, primary key +# name :string +# description :text +# created_at :datetime not null +# updated_at :datetime not null +# level :integer +# code :string +# deleted_at :datetime +# price_night_cents :integer default(0), not null # require "test_helper" diff --git a/yarn.lock b/yarn.lock index bf33c8b..c6ecae8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -539,6 +539,11 @@ is-number@^7.0.0: resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== +jquery@^3.7.1: + version "3.7.1" + resolved "https://registry.yarnpkg.com/jquery/-/jquery-3.7.1.tgz#083ef98927c9a6a74d05a6af02806566d16274de" + integrity sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg== + lilconfig@^2.0.5, lilconfig@^2.0.6: version "2.0.6" resolved "https://registry.yarnpkg.com/lilconfig/-/lilconfig-2.0.6.tgz#32a384558bd58af3d4c6e077dd1ad1d397bc69d4" @@ -755,6 +760,11 @@ run-parallel@^1.1.9: dependencies: queue-microtask "^1.2.2" +select2@^4.1.0-rc.0: + version "4.1.0-rc.0" + resolved "https://registry.yarnpkg.com/select2/-/select2-4.1.0-rc.0.tgz#ba3cd3901dda0155e1c0219ab41b74ba51ea22d8" + integrity sha512-Hr9TdhyHCZUtwznEH2CBf7967mEM0idtJ5nMtjvk3Up5tPukOLXbHUNmh10oRfeNIhj+3GD3niu+g6sVK+gK0A== + source-map-js@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.0.2.tgz#adbc361d9c62df380125e7f161f71c826f1e490c"