feat: add user and specimen management, setup OpenSpec workflow and Serena config
This commit is contained in:
parent
94af37dae5
commit
ad8b79c0c8
3
.gitignore
vendored
3
.gitignore
vendored
@ -22,7 +22,4 @@ Thumbs.db
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
|
||||
/.claude
|
||||
/.serena
|
||||
|
||||
static/config.json
|
||||
|
||||
149
.opencode/command/opsx-apply.md
Normal file
149
.opencode/command/opsx-apply.md
Normal file
@ -0,0 +1,149 @@
|
||||
---
|
||||
description: Implement tasks from an OpenSpec change (Experimental)
|
||||
---
|
||||
|
||||
Implement tasks from an OpenSpec change.
|
||||
|
||||
**Input**: Optionally specify a change name (e.g., `/opsx-apply add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
||||
|
||||
**Steps**
|
||||
|
||||
1. **Select the change**
|
||||
|
||||
If a name is provided, use it. Otherwise:
|
||||
- Infer from conversation context if the user mentioned a change
|
||||
- Auto-select if only one active change exists
|
||||
- If ambiguous, run `openspec list --json` to get available changes and use the **AskUserQuestion tool** to let the user select
|
||||
|
||||
Always announce: "Using change: <name>" and how to override (e.g., `/opsx-apply <other>`).
|
||||
|
||||
2. **Check status to understand the schema**
|
||||
```bash
|
||||
openspec status --change "<name>" --json
|
||||
```
|
||||
Parse the JSON to understand:
|
||||
- `schemaName`: The workflow being used (e.g., "spec-driven")
|
||||
- Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others)
|
||||
|
||||
3. **Get apply instructions**
|
||||
|
||||
```bash
|
||||
openspec instructions apply --change "<name>" --json
|
||||
```
|
||||
|
||||
This returns:
|
||||
- Context file paths (varies by schema)
|
||||
- Progress (total, complete, remaining)
|
||||
- Task list with status
|
||||
- Dynamic instruction based on current state
|
||||
|
||||
**Handle states:**
|
||||
- If `state: "blocked"` (missing artifacts): show message, suggest using `/opsx-continue`
|
||||
- If `state: "all_done"`: congratulate, suggest archive
|
||||
- Otherwise: proceed to implementation
|
||||
|
||||
4. **Read context files**
|
||||
|
||||
Read the files listed in `contextFiles` from the apply instructions output.
|
||||
The files depend on the schema being used:
|
||||
- **spec-driven**: proposal, specs, design, tasks
|
||||
- Other schemas: follow the contextFiles from CLI output
|
||||
|
||||
5. **Show current progress**
|
||||
|
||||
Display:
|
||||
- Schema being used
|
||||
- Progress: "N/M tasks complete"
|
||||
- Remaining tasks overview
|
||||
- Dynamic instruction from CLI
|
||||
|
||||
6. **Implement tasks (loop until done or blocked)**
|
||||
|
||||
For each pending task:
|
||||
- Show which task is being worked on
|
||||
- Make the code changes required
|
||||
- Keep changes minimal and focused
|
||||
- Mark task complete in the tasks file: `- [ ]` → `- [x]`
|
||||
- Continue to next task
|
||||
|
||||
**Pause if:**
|
||||
- Task is unclear → ask for clarification
|
||||
- Implementation reveals a design issue → suggest updating artifacts
|
||||
- Error or blocker encountered → report and wait for guidance
|
||||
- User interrupts
|
||||
|
||||
7. **On completion or pause, show status**
|
||||
|
||||
Display:
|
||||
- Tasks completed this session
|
||||
- Overall progress: "N/M tasks complete"
|
||||
- If all done: suggest archive
|
||||
- If paused: explain why and wait for guidance
|
||||
|
||||
**Output During Implementation**
|
||||
|
||||
```
|
||||
## Implementing: <change-name> (schema: <schema-name>)
|
||||
|
||||
Working on task 3/7: <task description>
|
||||
[...implementation happening...]
|
||||
✓ Task complete
|
||||
|
||||
Working on task 4/7: <task description>
|
||||
[...implementation happening...]
|
||||
✓ Task complete
|
||||
```
|
||||
|
||||
**Output On Completion**
|
||||
|
||||
```
|
||||
## Implementation Complete
|
||||
|
||||
**Change:** <change-name>
|
||||
**Schema:** <schema-name>
|
||||
**Progress:** 7/7 tasks complete ✓
|
||||
|
||||
### Completed This Session
|
||||
- [x] Task 1
|
||||
- [x] Task 2
|
||||
...
|
||||
|
||||
All tasks complete! You can archive this change with `/opsx-archive`.
|
||||
```
|
||||
|
||||
**Output On Pause (Issue Encountered)**
|
||||
|
||||
```
|
||||
## Implementation Paused
|
||||
|
||||
**Change:** <change-name>
|
||||
**Schema:** <schema-name>
|
||||
**Progress:** 4/7 tasks complete
|
||||
|
||||
### Issue Encountered
|
||||
<description of the issue>
|
||||
|
||||
**Options:**
|
||||
1. <option 1>
|
||||
2. <option 2>
|
||||
3. Other approach
|
||||
|
||||
What would you like to do?
|
||||
```
|
||||
|
||||
**Guardrails**
|
||||
- Keep going through tasks until done or blocked
|
||||
- Always read context files before starting (from the apply instructions output)
|
||||
- If task is ambiguous, pause and ask before implementing
|
||||
- If implementation reveals issues, pause and suggest artifact updates
|
||||
- Keep code changes minimal and scoped to each task
|
||||
- Update task checkbox immediately after completing each task
|
||||
- Pause on errors, blockers, or unclear requirements - don't guess
|
||||
- Use contextFiles from CLI output, don't assume specific file names
|
||||
|
||||
**Fluid Workflow Integration**
|
||||
|
||||
This skill supports the "actions on a change" model:
|
||||
|
||||
- **Can be invoked anytime**: Before all artifacts are done (if tasks exist), after partial implementation, interleaved with other actions
|
||||
- **Allows artifact updates**: If implementation reveals design issues, suggest updating artifacts - not phase-locked, work fluidly
|
||||
154
.opencode/command/opsx-archive.md
Normal file
154
.opencode/command/opsx-archive.md
Normal file
@ -0,0 +1,154 @@
|
||||
---
|
||||
description: Archive a completed change in the experimental workflow
|
||||
---
|
||||
|
||||
Archive a completed change in the experimental workflow.
|
||||
|
||||
**Input**: Optionally specify a change name after `/opsx-archive` (e.g., `/opsx-archive add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
||||
|
||||
**Steps**
|
||||
|
||||
1. **If no change name provided, prompt for selection**
|
||||
|
||||
Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
|
||||
|
||||
Show only active changes (not already archived).
|
||||
Include the schema used for each change if available.
|
||||
|
||||
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
|
||||
|
||||
2. **Check artifact completion status**
|
||||
|
||||
Run `openspec status --change "<name>" --json` to check artifact completion.
|
||||
|
||||
Parse the JSON to understand:
|
||||
- `schemaName`: The workflow being used
|
||||
- `artifacts`: List of artifacts with their status (`done` or other)
|
||||
|
||||
**If any artifacts are not `done`:**
|
||||
- Display warning listing incomplete artifacts
|
||||
- Prompt user for confirmation to continue
|
||||
- Proceed if user confirms
|
||||
|
||||
3. **Check task completion status**
|
||||
|
||||
Read the tasks file (typically `tasks.md`) to check for incomplete tasks.
|
||||
|
||||
Count tasks marked with `- [ ]` (incomplete) vs `- [x]` (complete).
|
||||
|
||||
**If incomplete tasks found:**
|
||||
- Display warning showing count of incomplete tasks
|
||||
- Prompt user for confirmation to continue
|
||||
- Proceed if user confirms
|
||||
|
||||
**If no tasks file exists:** Proceed without task-related warning.
|
||||
|
||||
4. **Assess delta spec sync state**
|
||||
|
||||
Check for delta specs at `openspec/changes/<name>/specs/`. If none exist, proceed without sync prompt.
|
||||
|
||||
**If delta specs exist:**
|
||||
- Compare each delta spec with its corresponding main spec at `openspec/specs/<capability>/spec.md`
|
||||
- Determine what changes would be applied (adds, modifications, removals, renames)
|
||||
- Show a combined summary before prompting
|
||||
|
||||
**Prompt options:**
|
||||
- If changes needed: "Sync now (recommended)", "Archive without syncing"
|
||||
- If already synced: "Archive now", "Sync anyway", "Cancel"
|
||||
|
||||
If user chooses sync, use Task tool (subagent_type: "general-purpose", prompt: "Use Skill tool to invoke openspec-sync-specs for change '<name>'. Delta spec analysis: <include the analyzed delta spec summary>"). Proceed to archive regardless of choice.
|
||||
|
||||
5. **Perform the archive**
|
||||
|
||||
Create the archive directory if it doesn't exist:
|
||||
```bash
|
||||
mkdir -p openspec/changes/archive
|
||||
```
|
||||
|
||||
Generate target name using current date: `YYYY-MM-DD-<change-name>`
|
||||
|
||||
**Check if target already exists:**
|
||||
- If yes: Fail with error, suggest renaming existing archive or using different date
|
||||
- If no: Move the change directory to archive
|
||||
|
||||
```bash
|
||||
mv openspec/changes/<name> openspec/changes/archive/YYYY-MM-DD-<name>
|
||||
```
|
||||
|
||||
6. **Display summary**
|
||||
|
||||
Show archive completion summary including:
|
||||
- Change name
|
||||
- Schema that was used
|
||||
- Archive location
|
||||
- Spec sync status (synced / sync skipped / no delta specs)
|
||||
- Note about any warnings (incomplete artifacts/tasks)
|
||||
|
||||
**Output On Success**
|
||||
|
||||
```
|
||||
## Archive Complete
|
||||
|
||||
**Change:** <change-name>
|
||||
**Schema:** <schema-name>
|
||||
**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
|
||||
**Specs:** ✓ Synced to main specs
|
||||
|
||||
All artifacts complete. All tasks complete.
|
||||
```
|
||||
|
||||
**Output On Success (No Delta Specs)**
|
||||
|
||||
```
|
||||
## Archive Complete
|
||||
|
||||
**Change:** <change-name>
|
||||
**Schema:** <schema-name>
|
||||
**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
|
||||
**Specs:** No delta specs
|
||||
|
||||
All artifacts complete. All tasks complete.
|
||||
```
|
||||
|
||||
**Output On Success With Warnings**
|
||||
|
||||
```
|
||||
## Archive Complete (with warnings)
|
||||
|
||||
**Change:** <change-name>
|
||||
**Schema:** <schema-name>
|
||||
**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
|
||||
**Specs:** Sync skipped (user chose to skip)
|
||||
|
||||
**Warnings:**
|
||||
- Archived with 2 incomplete artifacts
|
||||
- Archived with 3 incomplete tasks
|
||||
- Delta spec sync was skipped (user chose to skip)
|
||||
|
||||
Review the archive if this was not intentional.
|
||||
```
|
||||
|
||||
**Output On Error (Archive Exists)**
|
||||
|
||||
```
|
||||
## Archive Failed
|
||||
|
||||
**Change:** <change-name>
|
||||
**Target:** openspec/changes/archive/YYYY-MM-DD-<name>/
|
||||
|
||||
Target archive directory already exists.
|
||||
|
||||
**Options:**
|
||||
1. Rename the existing archive
|
||||
2. Delete the existing archive if it's a duplicate
|
||||
3. Wait until a different date to archive
|
||||
```
|
||||
|
||||
**Guardrails**
|
||||
- Always prompt for change selection if not provided
|
||||
- Use artifact graph (openspec status --json) for completion checking
|
||||
- Don't block archive on warnings - just inform and confirm
|
||||
- Preserve .openspec.yaml when moving to archive (it moves with the directory)
|
||||
- Show clear summary of what happened
|
||||
- If sync is requested, use the Skill tool to invoke `openspec-sync-specs` (agent-driven)
|
||||
- If delta specs exist, always run the sync assessment and show the combined summary before prompting
|
||||
170
.opencode/command/opsx-explore.md
Normal file
170
.opencode/command/opsx-explore.md
Normal file
@ -0,0 +1,170 @@
|
||||
---
|
||||
description: Enter explore mode - think through ideas, investigate problems, clarify requirements
|
||||
---
|
||||
|
||||
Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes.
|
||||
|
||||
**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, and investigate the codebase, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first and create a change proposal. You MAY create OpenSpec artifacts (proposals, designs, specs) if the user asks—that's capturing thinking, not implementing.
|
||||
|
||||
**This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore.
|
||||
|
||||
**Input**: The argument after `/opsx-explore` is whatever the user wants to think about. Could be:
|
||||
- A vague idea: "real-time collaboration"
|
||||
- A specific problem: "the auth system is getting unwieldy"
|
||||
- A change name: "add-dark-mode" (to explore in context of that change)
|
||||
- A comparison: "postgres vs sqlite for this"
|
||||
- Nothing (just enter explore mode)
|
||||
|
||||
---
|
||||
|
||||
## The Stance
|
||||
|
||||
- **Curious, not prescriptive** - Ask questions that emerge naturally, don't follow a script
|
||||
- **Open threads, not interrogations** - Surface multiple interesting directions and let the user follow what resonates. Don't funnel them through a single path of questions.
|
||||
- **Visual** - Use ASCII diagrams liberally when they'd help clarify thinking
|
||||
- **Adaptive** - Follow interesting threads, pivot when new information emerges
|
||||
- **Patient** - Don't rush to conclusions, let the shape of the problem emerge
|
||||
- **Grounded** - Explore the actual codebase when relevant, don't just theorize
|
||||
|
||||
---
|
||||
|
||||
## What You Might Do
|
||||
|
||||
Depending on what the user brings, you might:
|
||||
|
||||
**Explore the problem space**
|
||||
- Ask clarifying questions that emerge from what they said
|
||||
- Challenge assumptions
|
||||
- Reframe the problem
|
||||
- Find analogies
|
||||
|
||||
**Investigate the codebase**
|
||||
- Map existing architecture relevant to the discussion
|
||||
- Find integration points
|
||||
- Identify patterns already in use
|
||||
- Surface hidden complexity
|
||||
|
||||
**Compare options**
|
||||
- Brainstorm multiple approaches
|
||||
- Build comparison tables
|
||||
- Sketch tradeoffs
|
||||
- Recommend a path (if asked)
|
||||
|
||||
**Visualize**
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Use ASCII diagrams liberally │
|
||||
├─────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌────────┐ ┌────────┐ │
|
||||
│ │ State │────────▶│ State │ │
|
||||
│ │ A │ │ B │ │
|
||||
│ └────────┘ └────────┘ │
|
||||
│ │
|
||||
│ System diagrams, state machines, │
|
||||
│ data flows, architecture sketches, │
|
||||
│ dependency graphs, comparison tables │
|
||||
│ │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Surface risks and unknowns**
|
||||
- Identify what could go wrong
|
||||
- Find gaps in understanding
|
||||
- Suggest spikes or investigations
|
||||
|
||||
---
|
||||
|
||||
## OpenSpec Awareness
|
||||
|
||||
You have full context of the OpenSpec system. Use it naturally, don't force it.
|
||||
|
||||
### Check for context
|
||||
|
||||
At the start, quickly check what exists:
|
||||
```bash
|
||||
openspec list --json
|
||||
```
|
||||
|
||||
This tells you:
|
||||
- If there are active changes
|
||||
- Their names, schemas, and status
|
||||
- What the user might be working on
|
||||
|
||||
If the user mentioned a specific change name, read its artifacts for context.
|
||||
|
||||
### When no change exists
|
||||
|
||||
Think freely. When insights crystallize, you might offer:
|
||||
|
||||
- "This feels solid enough to start a change. Want me to create a proposal?"
|
||||
- Or keep exploring - no pressure to formalize
|
||||
|
||||
### When a change exists
|
||||
|
||||
If the user mentions a change or you detect one is relevant:
|
||||
|
||||
1. **Read existing artifacts for context**
|
||||
- `openspec/changes/<name>/proposal.md`
|
||||
- `openspec/changes/<name>/design.md`
|
||||
- `openspec/changes/<name>/tasks.md`
|
||||
- etc.
|
||||
|
||||
2. **Reference them naturally in conversation**
|
||||
- "Your design mentions using Redis, but we just realized SQLite fits better..."
|
||||
- "The proposal scopes this to premium users, but we're now thinking everyone..."
|
||||
|
||||
3. **Offer to capture when decisions are made**
|
||||
|
||||
| Insight Type | Where to Capture |
|
||||
|--------------|------------------|
|
||||
| New requirement discovered | `specs/<capability>/spec.md` |
|
||||
| Requirement changed | `specs/<capability>/spec.md` |
|
||||
| Design decision made | `design.md` |
|
||||
| Scope changed | `proposal.md` |
|
||||
| New work identified | `tasks.md` |
|
||||
| Assumption invalidated | Relevant artifact |
|
||||
|
||||
Example offers:
|
||||
- "That's a design decision. Capture it in design.md?"
|
||||
- "This is a new requirement. Add it to specs?"
|
||||
- "This changes scope. Update the proposal?"
|
||||
|
||||
4. **The user decides** - Offer and move on. Don't pressure. Don't auto-capture.
|
||||
|
||||
---
|
||||
|
||||
## What You Don't Have To Do
|
||||
|
||||
- Follow a script
|
||||
- Ask the same questions every time
|
||||
- Produce a specific artifact
|
||||
- Reach a conclusion
|
||||
- Stay on topic if a tangent is valuable
|
||||
- Be brief (this is thinking time)
|
||||
|
||||
---
|
||||
|
||||
## Ending Discovery
|
||||
|
||||
There's no required ending. Discovery might:
|
||||
|
||||
- **Flow into a proposal**: "Ready to start? I can create a change proposal."
|
||||
- **Result in artifact updates**: "Updated design.md with these decisions"
|
||||
- **Just provide clarity**: User has what they need, moves on
|
||||
- **Continue later**: "We can pick this up anytime"
|
||||
|
||||
When things crystallize, you might offer a summary - but it's optional. Sometimes the thinking IS the value.
|
||||
|
||||
---
|
||||
|
||||
## Guardrails
|
||||
|
||||
- **Don't implement** - Never write code or implement features. Creating OpenSpec artifacts is fine, writing application code is not.
|
||||
- **Don't fake understanding** - If something is unclear, dig deeper
|
||||
- **Don't rush** - Discovery is thinking time, not task time
|
||||
- **Don't force structure** - Let patterns emerge naturally
|
||||
- **Don't auto-capture** - Offer to save insights, don't just do it
|
||||
- **Do visualize** - A good diagram is worth many paragraphs
|
||||
- **Do explore the codebase** - Ground discussions in reality
|
||||
- **Do question assumptions** - Including the user's and your own
|
||||
103
.opencode/command/opsx-propose.md
Normal file
103
.opencode/command/opsx-propose.md
Normal file
@ -0,0 +1,103 @@
|
||||
---
|
||||
description: Propose a new change - create it and generate all artifacts in one step
|
||||
---
|
||||
|
||||
Propose a new change - create the change and generate all artifacts in one step.
|
||||
|
||||
I'll create a change with artifacts:
|
||||
- proposal.md (what & why)
|
||||
- design.md (how)
|
||||
- tasks.md (implementation steps)
|
||||
|
||||
When ready to implement, run /opsx-apply
|
||||
|
||||
---
|
||||
|
||||
**Input**: The argument after `/opsx-propose` is the change name (kebab-case), OR a description of what the user wants to build.
|
||||
|
||||
**Steps**
|
||||
|
||||
1. **If no input provided, ask what they want to build**
|
||||
|
||||
Use the **AskUserQuestion tool** (open-ended, no preset options) to ask:
|
||||
> "What change do you want to work on? Describe what you want to build or fix."
|
||||
|
||||
From their description, derive a kebab-case name (e.g., "add user authentication" → `add-user-auth`).
|
||||
|
||||
**IMPORTANT**: Do NOT proceed without understanding what the user wants to build.
|
||||
|
||||
2. **Create the change directory**
|
||||
```bash
|
||||
openspec new change "<name>"
|
||||
```
|
||||
This creates a scaffolded change at `openspec/changes/<name>/` with `.openspec.yaml`.
|
||||
|
||||
3. **Get the artifact build order**
|
||||
```bash
|
||||
openspec status --change "<name>" --json
|
||||
```
|
||||
Parse the JSON to get:
|
||||
- `applyRequires`: array of artifact IDs needed before implementation (e.g., `["tasks"]`)
|
||||
- `artifacts`: list of all artifacts with their status and dependencies
|
||||
|
||||
4. **Create artifacts in sequence until apply-ready**
|
||||
|
||||
Use the **TodoWrite tool** to track progress through the artifacts.
|
||||
|
||||
Loop through artifacts in dependency order (artifacts with no pending dependencies first):
|
||||
|
||||
a. **For each artifact that is `ready` (dependencies satisfied)**:
|
||||
- Get instructions:
|
||||
```bash
|
||||
openspec instructions <artifact-id> --change "<name>" --json
|
||||
```
|
||||
- The instructions JSON includes:
|
||||
- `context`: Project background (constraints for you - do NOT include in output)
|
||||
- `rules`: Artifact-specific rules (constraints for you - do NOT include in output)
|
||||
- `template`: The structure to use for your output file
|
||||
- `instruction`: Schema-specific guidance for this artifact type
|
||||
- `outputPath`: Where to write the artifact
|
||||
- `dependencies`: Completed artifacts to read for context
|
||||
- Read any completed dependency files for context
|
||||
- Create the artifact file using `template` as the structure
|
||||
- Apply `context` and `rules` as constraints - but do NOT copy them into the file
|
||||
- Show brief progress: "Created <artifact-id>"
|
||||
|
||||
b. **Continue until all `applyRequires` artifacts are complete**
|
||||
- After creating each artifact, re-run `openspec status --change "<name>" --json`
|
||||
- Check if every artifact ID in `applyRequires` has `status: "done"` in the artifacts array
|
||||
- Stop when all `applyRequires` artifacts are done
|
||||
|
||||
c. **If an artifact requires user input** (unclear context):
|
||||
- Use **AskUserQuestion tool** to clarify
|
||||
- Then continue with creation
|
||||
|
||||
5. **Show final status**
|
||||
```bash
|
||||
openspec status --change "<name>"
|
||||
```
|
||||
|
||||
**Output**
|
||||
|
||||
After completing all artifacts, summarize:
|
||||
- Change name and location
|
||||
- List of artifacts created with brief descriptions
|
||||
- What's ready: "All artifacts created! Ready for implementation."
|
||||
- Prompt: "Run `/opsx-apply` to start implementing."
|
||||
|
||||
**Artifact Creation Guidelines**
|
||||
|
||||
- Follow the `instruction` field from `openspec instructions` for each artifact type
|
||||
- The schema defines what each artifact should contain - follow it
|
||||
- Read dependency artifacts for context before creating new ones
|
||||
- Use `template` as the structure for your output file - fill in its sections
|
||||
- **IMPORTANT**: `context` and `rules` are constraints for YOU, not content for the file
|
||||
- Do NOT copy `<context>`, `<rules>`, `<project_context>` blocks into the artifact
|
||||
- These guide what you write, but should never appear in the output
|
||||
|
||||
**Guardrails**
|
||||
- Create ALL artifacts needed for implementation (as defined by schema's `apply.requires`)
|
||||
- Always read dependency artifacts before creating a new one
|
||||
- If context is critically unclear, ask the user - but prefer making reasonable decisions to keep momentum
|
||||
- If a change with that name already exists, ask if user wants to continue it or create a new one
|
||||
- Verify each artifact file exists after writing before proceeding to next
|
||||
156
.opencode/skills/openspec-apply-change/SKILL.md
Normal file
156
.opencode/skills/openspec-apply-change/SKILL.md
Normal file
@ -0,0 +1,156 @@
|
||||
---
|
||||
name: openspec-apply-change
|
||||
description: Implement tasks from an OpenSpec change. Use when the user wants to start implementing, continue implementation, or work through tasks.
|
||||
license: MIT
|
||||
compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.2.0"
|
||||
---
|
||||
|
||||
Implement tasks from an OpenSpec change.
|
||||
|
||||
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
||||
|
||||
**Steps**
|
||||
|
||||
1. **Select the change**
|
||||
|
||||
If a name is provided, use it. Otherwise:
|
||||
- Infer from conversation context if the user mentioned a change
|
||||
- Auto-select if only one active change exists
|
||||
- If ambiguous, run `openspec list --json` to get available changes and use the **AskUserQuestion tool** to let the user select
|
||||
|
||||
Always announce: "Using change: <name>" and how to override (e.g., `/opsx-apply <other>`).
|
||||
|
||||
2. **Check status to understand the schema**
|
||||
```bash
|
||||
openspec status --change "<name>" --json
|
||||
```
|
||||
Parse the JSON to understand:
|
||||
- `schemaName`: The workflow being used (e.g., "spec-driven")
|
||||
- Which artifact contains the tasks (typically "tasks" for spec-driven, check status for others)
|
||||
|
||||
3. **Get apply instructions**
|
||||
|
||||
```bash
|
||||
openspec instructions apply --change "<name>" --json
|
||||
```
|
||||
|
||||
This returns:
|
||||
- Context file paths (varies by schema - could be proposal/specs/design/tasks or spec/tests/implementation/docs)
|
||||
- Progress (total, complete, remaining)
|
||||
- Task list with status
|
||||
- Dynamic instruction based on current state
|
||||
|
||||
**Handle states:**
|
||||
- If `state: "blocked"` (missing artifacts): show message, suggest using openspec-continue-change
|
||||
- If `state: "all_done"`: congratulate, suggest archive
|
||||
- Otherwise: proceed to implementation
|
||||
|
||||
4. **Read context files**
|
||||
|
||||
Read the files listed in `contextFiles` from the apply instructions output.
|
||||
The files depend on the schema being used:
|
||||
- **spec-driven**: proposal, specs, design, tasks
|
||||
- Other schemas: follow the contextFiles from CLI output
|
||||
|
||||
5. **Show current progress**
|
||||
|
||||
Display:
|
||||
- Schema being used
|
||||
- Progress: "N/M tasks complete"
|
||||
- Remaining tasks overview
|
||||
- Dynamic instruction from CLI
|
||||
|
||||
6. **Implement tasks (loop until done or blocked)**
|
||||
|
||||
For each pending task:
|
||||
- Show which task is being worked on
|
||||
- Make the code changes required
|
||||
- Keep changes minimal and focused
|
||||
- Mark task complete in the tasks file: `- [ ]` → `- [x]`
|
||||
- Continue to next task
|
||||
|
||||
**Pause if:**
|
||||
- Task is unclear → ask for clarification
|
||||
- Implementation reveals a design issue → suggest updating artifacts
|
||||
- Error or blocker encountered → report and wait for guidance
|
||||
- User interrupts
|
||||
|
||||
7. **On completion or pause, show status**
|
||||
|
||||
Display:
|
||||
- Tasks completed this session
|
||||
- Overall progress: "N/M tasks complete"
|
||||
- If all done: suggest archive
|
||||
- If paused: explain why and wait for guidance
|
||||
|
||||
**Output During Implementation**
|
||||
|
||||
```
|
||||
## Implementing: <change-name> (schema: <schema-name>)
|
||||
|
||||
Working on task 3/7: <task description>
|
||||
[...implementation happening...]
|
||||
✓ Task complete
|
||||
|
||||
Working on task 4/7: <task description>
|
||||
[...implementation happening...]
|
||||
✓ Task complete
|
||||
```
|
||||
|
||||
**Output On Completion**
|
||||
|
||||
```
|
||||
## Implementation Complete
|
||||
|
||||
**Change:** <change-name>
|
||||
**Schema:** <schema-name>
|
||||
**Progress:** 7/7 tasks complete ✓
|
||||
|
||||
### Completed This Session
|
||||
- [x] Task 1
|
||||
- [x] Task 2
|
||||
...
|
||||
|
||||
All tasks complete! Ready to archive this change.
|
||||
```
|
||||
|
||||
**Output On Pause (Issue Encountered)**
|
||||
|
||||
```
|
||||
## Implementation Paused
|
||||
|
||||
**Change:** <change-name>
|
||||
**Schema:** <schema-name>
|
||||
**Progress:** 4/7 tasks complete
|
||||
|
||||
### Issue Encountered
|
||||
<description of the issue>
|
||||
|
||||
**Options:**
|
||||
1. <option 1>
|
||||
2. <option 2>
|
||||
3. Other approach
|
||||
|
||||
What would you like to do?
|
||||
```
|
||||
|
||||
**Guardrails**
|
||||
- Keep going through tasks until done or blocked
|
||||
- Always read context files before starting (from the apply instructions output)
|
||||
- If task is ambiguous, pause and ask before implementing
|
||||
- If implementation reveals issues, pause and suggest artifact updates
|
||||
- Keep code changes minimal and scoped to each task
|
||||
- Update task checkbox immediately after completing each task
|
||||
- Pause on errors, blockers, or unclear requirements - don't guess
|
||||
- Use contextFiles from CLI output, don't assume specific file names
|
||||
|
||||
**Fluid Workflow Integration**
|
||||
|
||||
This skill supports the "actions on a change" model:
|
||||
|
||||
- **Can be invoked anytime**: Before all artifacts are done (if tasks exist), after partial implementation, interleaved with other actions
|
||||
- **Allows artifact updates**: If implementation reveals design issues, suggest updating artifacts - not phase-locked, work fluidly
|
||||
114
.opencode/skills/openspec-archive-change/SKILL.md
Normal file
114
.opencode/skills/openspec-archive-change/SKILL.md
Normal file
@ -0,0 +1,114 @@
|
||||
---
|
||||
name: openspec-archive-change
|
||||
description: Archive a completed change in the experimental workflow. Use when the user wants to finalize and archive a change after implementation is complete.
|
||||
license: MIT
|
||||
compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.2.0"
|
||||
---
|
||||
|
||||
Archive a completed change in the experimental workflow.
|
||||
|
||||
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
||||
|
||||
**Steps**
|
||||
|
||||
1. **If no change name provided, prompt for selection**
|
||||
|
||||
Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
|
||||
|
||||
Show only active changes (not already archived).
|
||||
Include the schema used for each change if available.
|
||||
|
||||
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
|
||||
|
||||
2. **Check artifact completion status**
|
||||
|
||||
Run `openspec status --change "<name>" --json` to check artifact completion.
|
||||
|
||||
Parse the JSON to understand:
|
||||
- `schemaName`: The workflow being used
|
||||
- `artifacts`: List of artifacts with their status (`done` or other)
|
||||
|
||||
**If any artifacts are not `done`:**
|
||||
- Display warning listing incomplete artifacts
|
||||
- Use **AskUserQuestion tool** to confirm user wants to proceed
|
||||
- Proceed if user confirms
|
||||
|
||||
3. **Check task completion status**
|
||||
|
||||
Read the tasks file (typically `tasks.md`) to check for incomplete tasks.
|
||||
|
||||
Count tasks marked with `- [ ]` (incomplete) vs `- [x]` (complete).
|
||||
|
||||
**If incomplete tasks found:**
|
||||
- Display warning showing count of incomplete tasks
|
||||
- Use **AskUserQuestion tool** to confirm user wants to proceed
|
||||
- Proceed if user confirms
|
||||
|
||||
**If no tasks file exists:** Proceed without task-related warning.
|
||||
|
||||
4. **Assess delta spec sync state**
|
||||
|
||||
Check for delta specs at `openspec/changes/<name>/specs/`. If none exist, proceed without sync prompt.
|
||||
|
||||
**If delta specs exist:**
|
||||
- Compare each delta spec with its corresponding main spec at `openspec/specs/<capability>/spec.md`
|
||||
- Determine what changes would be applied (adds, modifications, removals, renames)
|
||||
- Show a combined summary before prompting
|
||||
|
||||
**Prompt options:**
|
||||
- If changes needed: "Sync now (recommended)", "Archive without syncing"
|
||||
- If already synced: "Archive now", "Sync anyway", "Cancel"
|
||||
|
||||
If user chooses sync, use Task tool (subagent_type: "general-purpose", prompt: "Use Skill tool to invoke openspec-sync-specs for change '<name>'. Delta spec analysis: <include the analyzed delta spec summary>"). Proceed to archive regardless of choice.
|
||||
|
||||
5. **Perform the archive**
|
||||
|
||||
Create the archive directory if it doesn't exist:
|
||||
```bash
|
||||
mkdir -p openspec/changes/archive
|
||||
```
|
||||
|
||||
Generate target name using current date: `YYYY-MM-DD-<change-name>`
|
||||
|
||||
**Check if target already exists:**
|
||||
- If yes: Fail with error, suggest renaming existing archive or using different date
|
||||
- If no: Move the change directory to archive
|
||||
|
||||
```bash
|
||||
mv openspec/changes/<name> openspec/changes/archive/YYYY-MM-DD-<name>
|
||||
```
|
||||
|
||||
6. **Display summary**
|
||||
|
||||
Show archive completion summary including:
|
||||
- Change name
|
||||
- Schema that was used
|
||||
- Archive location
|
||||
- Whether specs were synced (if applicable)
|
||||
- Note about any warnings (incomplete artifacts/tasks)
|
||||
|
||||
**Output On Success**
|
||||
|
||||
```
|
||||
## Archive Complete
|
||||
|
||||
**Change:** <change-name>
|
||||
**Schema:** <schema-name>
|
||||
**Archived to:** openspec/changes/archive/YYYY-MM-DD-<name>/
|
||||
**Specs:** ✓ Synced to main specs (or "No delta specs" or "Sync skipped")
|
||||
|
||||
All artifacts complete. All tasks complete.
|
||||
```
|
||||
|
||||
**Guardrails**
|
||||
- Always prompt for change selection if not provided
|
||||
- Use artifact graph (openspec status --json) for completion checking
|
||||
- Don't block archive on warnings - just inform and confirm
|
||||
- Preserve .openspec.yaml when moving to archive (it moves with the directory)
|
||||
- Show clear summary of what happened
|
||||
- If sync is requested, use openspec-sync-specs approach (agent-driven)
|
||||
- If delta specs exist, always run the sync assessment and show the combined summary before prompting
|
||||
288
.opencode/skills/openspec-explore/SKILL.md
Normal file
288
.opencode/skills/openspec-explore/SKILL.md
Normal file
@ -0,0 +1,288 @@
|
||||
---
|
||||
name: openspec-explore
|
||||
description: Enter explore mode - a thinking partner for exploring ideas, investigating problems, and clarifying requirements. Use when the user wants to think through something before or during a change.
|
||||
license: MIT
|
||||
compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.2.0"
|
||||
---
|
||||
|
||||
Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes.
|
||||
|
||||
**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, and investigate the codebase, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first and create a change proposal. You MAY create OpenSpec artifacts (proposals, designs, specs) if the user asks—that's capturing thinking, not implementing.
|
||||
|
||||
**This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore.
|
||||
|
||||
---
|
||||
|
||||
## The Stance
|
||||
|
||||
- **Curious, not prescriptive** - Ask questions that emerge naturally, don't follow a script
|
||||
- **Open threads, not interrogations** - Surface multiple interesting directions and let the user follow what resonates. Don't funnel them through a single path of questions.
|
||||
- **Visual** - Use ASCII diagrams liberally when they'd help clarify thinking
|
||||
- **Adaptive** - Follow interesting threads, pivot when new information emerges
|
||||
- **Patient** - Don't rush to conclusions, let the shape of the problem emerge
|
||||
- **Grounded** - Explore the actual codebase when relevant, don't just theorize
|
||||
|
||||
---
|
||||
|
||||
## What You Might Do
|
||||
|
||||
Depending on what the user brings, you might:
|
||||
|
||||
**Explore the problem space**
|
||||
- Ask clarifying questions that emerge from what they said
|
||||
- Challenge assumptions
|
||||
- Reframe the problem
|
||||
- Find analogies
|
||||
|
||||
**Investigate the codebase**
|
||||
- Map existing architecture relevant to the discussion
|
||||
- Find integration points
|
||||
- Identify patterns already in use
|
||||
- Surface hidden complexity
|
||||
|
||||
**Compare options**
|
||||
- Brainstorm multiple approaches
|
||||
- Build comparison tables
|
||||
- Sketch tradeoffs
|
||||
- Recommend a path (if asked)
|
||||
|
||||
**Visualize**
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Use ASCII diagrams liberally │
|
||||
├─────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌────────┐ ┌────────┐ │
|
||||
│ │ State │────────▶│ State │ │
|
||||
│ │ A │ │ B │ │
|
||||
│ └────────┘ └────────┘ │
|
||||
│ │
|
||||
│ System diagrams, state machines, │
|
||||
│ data flows, architecture sketches, │
|
||||
│ dependency graphs, comparison tables │
|
||||
│ │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Surface risks and unknowns**
|
||||
- Identify what could go wrong
|
||||
- Find gaps in understanding
|
||||
- Suggest spikes or investigations
|
||||
|
||||
---
|
||||
|
||||
## OpenSpec Awareness
|
||||
|
||||
You have full context of the OpenSpec system. Use it naturally, don't force it.
|
||||
|
||||
### Check for context
|
||||
|
||||
At the start, quickly check what exists:
|
||||
```bash
|
||||
openspec list --json
|
||||
```
|
||||
|
||||
This tells you:
|
||||
- If there are active changes
|
||||
- Their names, schemas, and status
|
||||
- What the user might be working on
|
||||
|
||||
### When no change exists
|
||||
|
||||
Think freely. When insights crystallize, you might offer:
|
||||
|
||||
- "This feels solid enough to start a change. Want me to create a proposal?"
|
||||
- Or keep exploring - no pressure to formalize
|
||||
|
||||
### When a change exists
|
||||
|
||||
If the user mentions a change or you detect one is relevant:
|
||||
|
||||
1. **Read existing artifacts for context**
|
||||
- `openspec/changes/<name>/proposal.md`
|
||||
- `openspec/changes/<name>/design.md`
|
||||
- `openspec/changes/<name>/tasks.md`
|
||||
- etc.
|
||||
|
||||
2. **Reference them naturally in conversation**
|
||||
- "Your design mentions using Redis, but we just realized SQLite fits better..."
|
||||
- "The proposal scopes this to premium users, but we're now thinking everyone..."
|
||||
|
||||
3. **Offer to capture when decisions are made**
|
||||
|
||||
| Insight Type | Where to Capture |
|
||||
|--------------|------------------|
|
||||
| New requirement discovered | `specs/<capability>/spec.md` |
|
||||
| Requirement changed | `specs/<capability>/spec.md` |
|
||||
| Design decision made | `design.md` |
|
||||
| Scope changed | `proposal.md` |
|
||||
| New work identified | `tasks.md` |
|
||||
| Assumption invalidated | Relevant artifact |
|
||||
|
||||
Example offers:
|
||||
- "That's a design decision. Capture it in design.md?"
|
||||
- "This is a new requirement. Add it to specs?"
|
||||
- "This changes scope. Update the proposal?"
|
||||
|
||||
4. **The user decides** - Offer and move on. Don't pressure. Don't auto-capture.
|
||||
|
||||
---
|
||||
|
||||
## What You Don't Have To Do
|
||||
|
||||
- Follow a script
|
||||
- Ask the same questions every time
|
||||
- Produce a specific artifact
|
||||
- Reach a conclusion
|
||||
- Stay on topic if a tangent is valuable
|
||||
- Be brief (this is thinking time)
|
||||
|
||||
---
|
||||
|
||||
## Handling Different Entry Points
|
||||
|
||||
**User brings a vague idea:**
|
||||
```
|
||||
User: I'm thinking about adding real-time collaboration
|
||||
|
||||
You: Real-time collab is a big space. Let me think about this...
|
||||
|
||||
COLLABORATION SPECTRUM
|
||||
════════════════════════════════════════════
|
||||
|
||||
Awareness Coordination Sync
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌────────┐ ┌────────┐ ┌────────┐
|
||||
│Presence│ │Cursors │ │ CRDT │
|
||||
│ "3 │ │ Multi │ │Conflict│
|
||||
│online" │ │ select │ │ free │
|
||||
└────────┘ └────────┘ └────────┘
|
||||
│ │ │
|
||||
trivial moderate complex
|
||||
|
||||
Where's your head at?
|
||||
```
|
||||
|
||||
**User brings a specific problem:**
|
||||
```
|
||||
User: The auth system is a mess
|
||||
|
||||
You: [reads codebase]
|
||||
|
||||
┌─────────────────────────────────────────────┐
|
||||
│ CURRENT AUTH FLOW │
|
||||
└─────────────────────────────────────────────┘
|
||||
│
|
||||
┌─────────────┼─────────────┐
|
||||
▼ ▼ ▼
|
||||
┌─────────┐ ┌─────────┐ ┌─────────┐
|
||||
│ Google │ │ GitHub │ │ Email │
|
||||
│ OAuth │ │ OAuth │ │ Magic │
|
||||
└────┬────┘ └────┬────┘ └────┬────┘
|
||||
│ │ │
|
||||
└─────────────┼─────────────┘
|
||||
▼
|
||||
┌───────────┐
|
||||
│ Session │
|
||||
└─────┬─────┘
|
||||
│
|
||||
▼
|
||||
┌───────────┐
|
||||
│ Perms │
|
||||
└───────────┘
|
||||
|
||||
I see three tangles. Which one's burning?
|
||||
```
|
||||
|
||||
**User is stuck mid-implementation:**
|
||||
```
|
||||
User: /opsx-explore add-auth-system
|
||||
The OAuth integration is more complex than expected
|
||||
|
||||
You: [reads change artifacts]
|
||||
|
||||
You're on task 4: "Implement OAuth flow"
|
||||
|
||||
Let me trace what's involved...
|
||||
|
||||
[draws diagram, explores options, suggests paths]
|
||||
|
||||
Want to update the design to reflect this?
|
||||
Or add a spike task to investigate?
|
||||
```
|
||||
|
||||
**User wants to compare options:**
|
||||
```
|
||||
User: Should we use Postgres or SQLite?
|
||||
|
||||
You: Generic answer is boring. What's the context?
|
||||
|
||||
User: A CLI tool that tracks local dev environments
|
||||
|
||||
You: That changes everything.
|
||||
|
||||
┌─────────────────────────────────────────────────┐
|
||||
│ CLI TOOL DATA STORAGE │
|
||||
└─────────────────────────────────────────────────┘
|
||||
|
||||
Key constraints:
|
||||
• No daemon running
|
||||
• Must work offline
|
||||
• Single user
|
||||
|
||||
SQLite Postgres
|
||||
Deployment embedded ✓ needs server ✗
|
||||
Offline yes ✓ no ✗
|
||||
Single file yes ✓ no ✗
|
||||
|
||||
SQLite. Not even close.
|
||||
|
||||
Unless... is there a sync component?
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Ending Discovery
|
||||
|
||||
There's no required ending. Discovery might:
|
||||
|
||||
- **Flow into a proposal**: "Ready to start? I can create a change proposal."
|
||||
- **Result in artifact updates**: "Updated design.md with these decisions"
|
||||
- **Just provide clarity**: User has what they need, moves on
|
||||
- **Continue later**: "We can pick this up anytime"
|
||||
|
||||
When it feels like things are crystallizing, you might summarize:
|
||||
|
||||
```
|
||||
## What We Figured Out
|
||||
|
||||
**The problem**: [crystallized understanding]
|
||||
|
||||
**The approach**: [if one emerged]
|
||||
|
||||
**Open questions**: [if any remain]
|
||||
|
||||
**Next steps** (if ready):
|
||||
- Create a change proposal
|
||||
- Keep exploring: just keep talking
|
||||
```
|
||||
|
||||
But this summary is optional. Sometimes the thinking IS the value.
|
||||
|
||||
---
|
||||
|
||||
## Guardrails
|
||||
|
||||
- **Don't implement** - Never write code or implement features. Creating OpenSpec artifacts is fine, writing application code is not.
|
||||
- **Don't fake understanding** - If something is unclear, dig deeper
|
||||
- **Don't rush** - Discovery is thinking time, not task time
|
||||
- **Don't force structure** - Let patterns emerge naturally
|
||||
- **Don't auto-capture** - Offer to save insights, don't just do it
|
||||
- **Do visualize** - A good diagram is worth many paragraphs
|
||||
- **Do explore the codebase** - Ground discussions in reality
|
||||
- **Do question assumptions** - Including the user's and your own
|
||||
110
.opencode/skills/openspec-propose/SKILL.md
Normal file
110
.opencode/skills/openspec-propose/SKILL.md
Normal file
@ -0,0 +1,110 @@
|
||||
---
|
||||
name: openspec-propose
|
||||
description: Propose a new change with all artifacts generated in one step. Use when the user wants to quickly describe what they want to build and get a complete proposal with design, specs, and tasks ready for implementation.
|
||||
license: MIT
|
||||
compatibility: Requires openspec CLI.
|
||||
metadata:
|
||||
author: openspec
|
||||
version: "1.0"
|
||||
generatedBy: "1.2.0"
|
||||
---
|
||||
|
||||
Propose a new change - create the change and generate all artifacts in one step.
|
||||
|
||||
I'll create a change with artifacts:
|
||||
- proposal.md (what & why)
|
||||
- design.md (how)
|
||||
- tasks.md (implementation steps)
|
||||
|
||||
When ready to implement, run /opsx-apply
|
||||
|
||||
---
|
||||
|
||||
**Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build.
|
||||
|
||||
**Steps**
|
||||
|
||||
1. **If no clear input provided, ask what they want to build**
|
||||
|
||||
Use the **AskUserQuestion tool** (open-ended, no preset options) to ask:
|
||||
> "What change do you want to work on? Describe what you want to build or fix."
|
||||
|
||||
From their description, derive a kebab-case name (e.g., "add user authentication" → `add-user-auth`).
|
||||
|
||||
**IMPORTANT**: Do NOT proceed without understanding what the user wants to build.
|
||||
|
||||
2. **Create the change directory**
|
||||
```bash
|
||||
openspec new change "<name>"
|
||||
```
|
||||
This creates a scaffolded change at `openspec/changes/<name>/` with `.openspec.yaml`.
|
||||
|
||||
3. **Get the artifact build order**
|
||||
```bash
|
||||
openspec status --change "<name>" --json
|
||||
```
|
||||
Parse the JSON to get:
|
||||
- `applyRequires`: array of artifact IDs needed before implementation (e.g., `["tasks"]`)
|
||||
- `artifacts`: list of all artifacts with their status and dependencies
|
||||
|
||||
4. **Create artifacts in sequence until apply-ready**
|
||||
|
||||
Use the **TodoWrite tool** to track progress through the artifacts.
|
||||
|
||||
Loop through artifacts in dependency order (artifacts with no pending dependencies first):
|
||||
|
||||
a. **For each artifact that is `ready` (dependencies satisfied)**:
|
||||
- Get instructions:
|
||||
```bash
|
||||
openspec instructions <artifact-id> --change "<name>" --json
|
||||
```
|
||||
- The instructions JSON includes:
|
||||
- `context`: Project background (constraints for you - do NOT include in output)
|
||||
- `rules`: Artifact-specific rules (constraints for you - do NOT include in output)
|
||||
- `template`: The structure to use for your output file
|
||||
- `instruction`: Schema-specific guidance for this artifact type
|
||||
- `outputPath`: Where to write the artifact
|
||||
- `dependencies`: Completed artifacts to read for context
|
||||
- Read any completed dependency files for context
|
||||
- Create the artifact file using `template` as the structure
|
||||
- Apply `context` and `rules` as constraints - but do NOT copy them into the file
|
||||
- Show brief progress: "Created <artifact-id>"
|
||||
|
||||
b. **Continue until all `applyRequires` artifacts are complete**
|
||||
- After creating each artifact, re-run `openspec status --change "<name>" --json`
|
||||
- Check if every artifact ID in `applyRequires` has `status: "done"` in the artifacts array
|
||||
- Stop when all `applyRequires` artifacts are done
|
||||
|
||||
c. **If an artifact requires user input** (unclear context):
|
||||
- Use **AskUserQuestion tool** to clarify
|
||||
- Then continue with creation
|
||||
|
||||
5. **Show final status**
|
||||
```bash
|
||||
openspec status --change "<name>"
|
||||
```
|
||||
|
||||
**Output**
|
||||
|
||||
After completing all artifacts, summarize:
|
||||
- Change name and location
|
||||
- List of artifacts created with brief descriptions
|
||||
- What's ready: "All artifacts created! Ready for implementation."
|
||||
- Prompt: "Run `/opsx-apply` or ask me to implement to start working on the tasks."
|
||||
|
||||
**Artifact Creation Guidelines**
|
||||
|
||||
- Follow the `instruction` field from `openspec instructions` for each artifact type
|
||||
- The schema defines what each artifact should contain - follow it
|
||||
- Read dependency artifacts for context before creating new ones
|
||||
- Use `template` as the structure for your output file - fill in its sections
|
||||
- **IMPORTANT**: `context` and `rules` are constraints for YOU, not content for the file
|
||||
- Do NOT copy `<context>`, `<rules>`, `<project_context>` blocks into the artifact
|
||||
- These guide what you write, but should never appear in the output
|
||||
|
||||
**Guardrails**
|
||||
- Create ALL artifacts needed for implementation (as defined by schema's `apply.requires`)
|
||||
- Always read dependency artifacts before creating a new one
|
||||
- If context is critically unclear, ask the user - but prefer making reasonable decisions to keep momentum
|
||||
- If a change with that name already exists, ask if user wants to continue it or create a new one
|
||||
- Verify each artifact file exists after writing before proceeding to next
|
||||
1
.serena/.gitignore
vendored
Normal file
1
.serena/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
/cache
|
||||
62
.serena/memories/project_overview.md
Normal file
62
.serena/memories/project_overview.md
Normal file
@ -0,0 +1,62 @@
|
||||
# CLQMS Frontend - Project Overview
|
||||
|
||||
## Project Purpose
|
||||
CLQMS (Clinical Laboratory Quality Management System) frontend built with SvelteKit. This is a web application for managing clinical laboratory operations including patients, specimens, test orders, results, and laboratory workflows.
|
||||
|
||||
## Tech Stack
|
||||
- **Framework**: SvelteKit (latest with Svelte 5 runes)
|
||||
- **Styling**: TailwindCSS 4 + DaisyUI
|
||||
- **Icons**: Lucide Svelte
|
||||
- **Language**: JavaScript (no TypeScript - KISS principle)
|
||||
- **Build Tool**: Vite
|
||||
- **Package Manager**: pnpm
|
||||
- **Authentication**: JWT tokens with HTTP-only cookies
|
||||
|
||||
## Architecture Principles
|
||||
- **KISS**: Keep It Simple - plain JavaScript, minimal tooling
|
||||
- **Manual API Wrapper**: No codegen, simple fetch-based API client
|
||||
- **File-based Routing**: Standard SvelteKit routing patterns
|
||||
- **Server-side Auth Check**: SvelteKit hooks for session validation
|
||||
|
||||
## Project Structure
|
||||
```
|
||||
src/
|
||||
├── lib/
|
||||
│ ├── api/ # API service functions (client.js, auth.js, valuesets.js, etc.)
|
||||
│ ├── components/ # Reusable Svelte components (DataTable, Modal, SelectDropdown, Sidebar, etc.)
|
||||
│ ├── stores/ # Svelte stores (auth.js, valuesets.js)
|
||||
│ └── utils/ # Utility functions (toast.js)
|
||||
├── routes/ # SvelteKit routes
|
||||
│ ├── +layout.svelte # Root layout
|
||||
│ ├── login/ # Login page
|
||||
│ └── (app)/ # Protected route group
|
||||
│ ├── +layout.svelte # Auth check with sidebar
|
||||
│ ├── dashboard/
|
||||
│ ├── master-data/ # Locations, contacts, specialties, valuesets, geography, occupations, counters
|
||||
│ └── patients/
|
||||
├── app.css # Global styles with Tailwind
|
||||
└── app.html # HTML template
|
||||
```
|
||||
|
||||
## Key Features Implemented
|
||||
- Phase 0: Foundation (Auth, base API client, layouts) ✅
|
||||
- Phase 1: Foundation Data (ValueSets, Locations, Contacts, Occupations, Specialties, Counters, Geography) ✅
|
||||
- Phase 2a: Patient CRUD ✅
|
||||
- Phase 11: Authentication (login/logout, JWT handling) ✅
|
||||
|
||||
## Pending Features
|
||||
- Phase 2b: Advanced Patient Features
|
||||
- Phase 3: Patient Visits (list page exists, needs create/edit forms)
|
||||
- Phase 4: Specimen Management
|
||||
- Phase 5: Test Catalog
|
||||
- Phase 6: Orders
|
||||
- Phase 7: Results & Dashboard
|
||||
- Phase 8: User-defined ValueSets
|
||||
- Phase 9: Organization Structure
|
||||
- Phase 10: Edge API (Instrument Integration)
|
||||
|
||||
## API Proxy Configuration
|
||||
API requests to `/api` are proxied to `http://localhost:8000` in development mode (configured in vite.config.js).
|
||||
|
||||
## Environment Variables
|
||||
- `VITE_API_URL`: Base URL for API (default: empty string, uses proxy in dev)
|
||||
22
.serena/memories/refactoring/patient-page.md
Normal file
22
.serena/memories/refactoring/patient-page.md
Normal file
@ -0,0 +1,22 @@
|
||||
# Patient Page Refactoring
|
||||
|
||||
## Current Architecture Issues
|
||||
- File: `src/routes/(app)/patients/+page.svelte` (543 lines)
|
||||
- Mixes patient CRUD + visit management + ADT history in one file
|
||||
- Inline visit card rendering (~100 lines)
|
||||
- Helper functions at bottom (formatDate, formatDateTime)
|
||||
- Patient name formatting repeated 3+ times inline
|
||||
|
||||
## Refactoring Plan
|
||||
1. Extract VisitCard.svelte component
|
||||
2. Create patientUtils.js for shared helpers
|
||||
3. Group modal states into objects (patientModals, visitModals)
|
||||
4. Use $derived for computed values (patientFullName, formattedVisits)
|
||||
5. Add JSDoc types for Patient and Visit
|
||||
|
||||
## Related Files
|
||||
- PatientFormModal.svelte
|
||||
- VisitFormModal.svelte
|
||||
- VisitADTHistoryModal.svelte
|
||||
- $lib/api/patients.js
|
||||
- $lib/api/visits.js
|
||||
156
.serena/memories/style_and_conventions.md
Normal file
156
.serena/memories/style_and_conventions.md
Normal file
@ -0,0 +1,156 @@
|
||||
# CLQMS Frontend - Code Style and Conventions
|
||||
|
||||
## JavaScript/TypeScript
|
||||
- **Language**: Plain JavaScript (no TypeScript)
|
||||
- **Modules**: Always use `import`/`export` (type: "module" in package.json)
|
||||
- **Semicolons**: Use semicolons consistently
|
||||
- **Quotes**: Use single quotes for strings
|
||||
- **Indentation**: 2 spaces
|
||||
- **Trailing commas**: Use in multi-line objects/arrays
|
||||
- **JSDoc**: Document all exported functions with JSDoc comments
|
||||
|
||||
## Svelte Components (Svelte 5 Runes)
|
||||
|
||||
### Component Structure
|
||||
```svelte
|
||||
<script>
|
||||
// 1. Imports first - group by: Svelte, $lib, external
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { auth } from '$lib/stores/auth.js';
|
||||
import { login } from '$lib/api/auth.js';
|
||||
import { Icon } from 'lucide-svelte';
|
||||
|
||||
// 2. Props (Svelte 5 runes)
|
||||
let { children, data } = $props();
|
||||
|
||||
// 3. State
|
||||
let loading = $state(false);
|
||||
let error = $state('');
|
||||
|
||||
// 4. Derived state (if needed)
|
||||
let isValid = $derived(username.length > 0);
|
||||
|
||||
// 5. Effects
|
||||
$effect(() => {
|
||||
// side effects
|
||||
});
|
||||
|
||||
// 6. Functions
|
||||
function handleSubmit() {
|
||||
// implementation
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
## Naming Conventions
|
||||
- **Components**: PascalCase (`LoginForm.svelte`, `DataTable.svelte`)
|
||||
- **Files/Routes**: lowercase with hyphens (`+page.svelte`, `user-profile/`)
|
||||
- **Variables**: camelCase (`isLoading`, `userName`)
|
||||
- **Constants**: UPPER_SNAKE_CASE (`API_URL`, `STORAGE_KEY`)
|
||||
- **Stores**: camelCase, descriptive (`auth`, `valuesets`)
|
||||
- **Event handlers**: prefix with `handle` (`handleSubmit`, `handleClick`)
|
||||
|
||||
## Imports Order
|
||||
1. Svelte imports (`svelte`, `$app/*`)
|
||||
2. $lib aliases (`$lib/stores/*`, `$lib/api/*`, `$lib/components/*`)
|
||||
3. External libraries (`lucide-svelte`)
|
||||
4. Relative imports (minimize these, prefer `$lib`)
|
||||
|
||||
## API Client Pattern
|
||||
|
||||
### Base Client (src/lib/api/client.js)
|
||||
The base client handles JWT token management and 401 redirects automatically.
|
||||
|
||||
### Feature-Specific API Modules
|
||||
```javascript
|
||||
// src/lib/api/feature.js - Feature-specific endpoints
|
||||
import { apiClient, get, post, put, patch, del } from '$lib/api/client.js';
|
||||
|
||||
export async function getItem(id) {
|
||||
return get(`/api/items/${id}`);
|
||||
}
|
||||
|
||||
export async function createItem(data) {
|
||||
return post('/api/items', data);
|
||||
}
|
||||
```
|
||||
|
||||
## Form Handling Pattern
|
||||
```javascript
|
||||
let formData = { name: '', email: '' };
|
||||
let errors = {};
|
||||
let loading = false;
|
||||
|
||||
async function handleSubmit() {
|
||||
loading = true;
|
||||
errors = {};
|
||||
|
||||
try {
|
||||
const result = await createItem(formData);
|
||||
|
||||
if (result.status === 'error') {
|
||||
errors = result.errors || { general: result.message };
|
||||
} else {
|
||||
// Success - redirect or show message
|
||||
}
|
||||
} catch (err) {
|
||||
errors = { general: err.message || 'An unexpected error occurred' };
|
||||
}
|
||||
|
||||
loading = false;
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
- API errors are thrown with message
|
||||
- Use try/catch blocks for async operations
|
||||
- Store errors in state for display
|
||||
- Toast notifications for user feedback
|
||||
|
||||
```javascript
|
||||
try {
|
||||
const result = await api.login(username, password);
|
||||
toast.success('Login successful');
|
||||
} catch (err) {
|
||||
error = err.message || 'An unexpected error occurred';
|
||||
console.error('Login failed:', err);
|
||||
toast.error('Login failed');
|
||||
}
|
||||
```
|
||||
|
||||
## Styling with Tailwind & DaisyUI
|
||||
- Use Tailwind utility classes
|
||||
- DaisyUI components: `btn`, `card`, `alert`, `input`, `navbar`, `select`
|
||||
- Color scheme: `primary` (emerald), `base-100`, `base-200`
|
||||
- Custom colors in `app.css` with `@theme`
|
||||
|
||||
## Authentication Patterns
|
||||
- Auth state in `$lib/stores/auth.js`
|
||||
- Check auth in layout `onMount` or `+layout.server.js`
|
||||
- Redirect to `/login` if unauthenticated
|
||||
- API client auto-redirects on 401
|
||||
|
||||
## LocalStorage
|
||||
- Only access in browser: check `browser` from `$app/environment`
|
||||
- Use descriptive keys: `auth_token`
|
||||
|
||||
## Reusable Components
|
||||
- **DataTable.svelte**: Sortable, paginated table with actions
|
||||
- **Modal.svelte**: Reusable modal for confirmations and forms
|
||||
- **SelectDropdown.svelte**: Dropdown populated from ValueSets or API data
|
||||
- **Sidebar.svelte**: Navigation sidebar
|
||||
- **ToastContainer.svelte**: Toast notifications
|
||||
|
||||
## SvelteKit Patterns
|
||||
- File-based routing with `+page.svelte`, `+layout.svelte`
|
||||
- Route groups with `(app)` for protected routes
|
||||
- Load data in `+page.js` or `+page.server.js` if needed
|
||||
- Use `invalidateAll()` after mutations to refresh data
|
||||
|
||||
## Code Quality Notes
|
||||
- No ESLint or Prettier configured yet
|
||||
- No test framework configured yet (plan: Vitest for unit tests, Playwright for E2E)
|
||||
- JSDoc comments are required for all exported functions
|
||||
- Keep components focused and reusable
|
||||
- Extract logic into utility functions when possible
|
||||
219
.serena/memories/suggested_commands.md
Normal file
219
.serena/memories/suggested_commands.md
Normal file
@ -0,0 +1,219 @@
|
||||
# CLQMS Frontend - Suggested Commands
|
||||
|
||||
## Development Commands
|
||||
|
||||
```bash
|
||||
# Start development server
|
||||
pnpm run dev
|
||||
|
||||
# Start development server and open in browser
|
||||
pnpm run dev -- --open
|
||||
|
||||
# Production build
|
||||
pnpm run build
|
||||
|
||||
# Preview production build
|
||||
pnpm run preview
|
||||
|
||||
# Sync SvelteKit (runs automatically on install)
|
||||
pnpm run prepare
|
||||
```
|
||||
|
||||
## Package Manager
|
||||
- **Primary**: `pnpm` (preferred)
|
||||
- Can also use `npm` or `yarn` if needed
|
||||
|
||||
## Development Server
|
||||
- Dev server runs by default on `http://localhost:5173`
|
||||
- API requests to `/api` are proxied to `http://localhost:8000`
|
||||
- Hot module replacement (HMR) enabled
|
||||
|
||||
## Windows System Commands
|
||||
Since the system is Windows, use these commands:
|
||||
|
||||
```bash
|
||||
# List files
|
||||
dir
|
||||
|
||||
# Change directory
|
||||
cd path\to\directory
|
||||
|
||||
# Search for files
|
||||
dir /s filename
|
||||
|
||||
# Search for text in files
|
||||
findstr /s /i "searchterm" *.js
|
||||
|
||||
# Delete files
|
||||
del filename
|
||||
|
||||
# Delete directories
|
||||
rmdir /s /q directoryname
|
||||
|
||||
# Copy files
|
||||
copy source destination
|
||||
|
||||
# Move files
|
||||
move source destination
|
||||
|
||||
# Create directory
|
||||
mkdir directoryname
|
||||
|
||||
# Display file content
|
||||
type filename
|
||||
|
||||
# Edit files (use VS Code or other editor)
|
||||
code filename
|
||||
```
|
||||
|
||||
## Git Commands
|
||||
```bash
|
||||
# Check git status
|
||||
git status
|
||||
|
||||
# View changes
|
||||
git diff
|
||||
|
||||
# Stage changes
|
||||
git add .
|
||||
|
||||
# Commit changes
|
||||
git commit -m "commit message"
|
||||
|
||||
# Push to remote
|
||||
git push
|
||||
|
||||
# Pull from remote
|
||||
git pull
|
||||
|
||||
# Create new branch
|
||||
git branch branch-name
|
||||
|
||||
# Switch branch
|
||||
git checkout branch-name
|
||||
|
||||
# View commit history
|
||||
git log --oneline
|
||||
```
|
||||
|
||||
## Testing Commands (when configured)
|
||||
```bash
|
||||
# Run all tests (Vitest - when configured)
|
||||
pnpm test
|
||||
|
||||
# Run tests in watch mode
|
||||
pnpm test -- --watch
|
||||
|
||||
# Run single test file
|
||||
pnpm test src/path/to/test.js
|
||||
|
||||
# Run E2E tests (Playwright - when configured)
|
||||
pnpm run test:e2e
|
||||
|
||||
# Run E2E tests in headless mode
|
||||
pnpm run test:e2e -- --headed=false
|
||||
```
|
||||
|
||||
## Linting and Formatting (when configured)
|
||||
```bash
|
||||
# Run ESLint (when configured)
|
||||
pnpm run lint
|
||||
|
||||
# Auto-fix lint issues
|
||||
pnpm run lint -- --fix
|
||||
|
||||
# Format code with Prettier (when configured)
|
||||
pnpm run format
|
||||
```
|
||||
|
||||
## Environment Setup
|
||||
```bash
|
||||
# Install dependencies
|
||||
pnpm install
|
||||
|
||||
# Create .env file for environment variables
|
||||
echo "VITE_API_URL=http://localhost:8000" > .env
|
||||
|
||||
# Install dependencies (if package-lock.json exists)
|
||||
npm install
|
||||
```
|
||||
|
||||
## Useful pnpm Commands
|
||||
```bash
|
||||
# Add a dependency
|
||||
pnpm add package-name
|
||||
|
||||
# Add a dev dependency
|
||||
pnpm add -D package-name
|
||||
|
||||
# Update dependencies
|
||||
pnpm update
|
||||
|
||||
# Remove a dependency
|
||||
pnpm remove package-name
|
||||
|
||||
# List installed packages
|
||||
pnpm list --depth 0
|
||||
|
||||
# Check for outdated packages
|
||||
pnpm outdated
|
||||
```
|
||||
|
||||
## Build and Deploy
|
||||
```bash
|
||||
# Build for production
|
||||
pnpm run build
|
||||
|
||||
# Preview production build locally
|
||||
pnpm run preview
|
||||
|
||||
# Clean build artifacts (if clean script exists)
|
||||
pnpm run clean
|
||||
```
|
||||
|
||||
## SvelteKit Specific Commands
|
||||
```bash
|
||||
# Sync SvelteKit type definitions
|
||||
pnpm run prepare
|
||||
|
||||
# Check SvelteKit configuration
|
||||
pnpm run check
|
||||
|
||||
# Generate types (if using TypeScript)
|
||||
pnpm run check:types
|
||||
```
|
||||
|
||||
## Common Troubleshooting
|
||||
```bash
|
||||
# Clear pnpm cache
|
||||
pnpm store prune
|
||||
|
||||
# Reinstall all dependencies
|
||||
rm -rf node_modules pnpm-lock.yaml && pnpm install
|
||||
|
||||
# Clear Vite cache
|
||||
rm -rf .vite
|
||||
|
||||
# Check Node.js version
|
||||
node --version
|
||||
|
||||
# Check pnpm version
|
||||
pnpm --version
|
||||
```
|
||||
|
||||
## Development Workflow
|
||||
1. Start dev server: `pnpm run dev`
|
||||
2. Open browser to `http://localhost:5173`
|
||||
3. Make changes to files
|
||||
4. See HMR updates in browser
|
||||
5. Test changes
|
||||
6. Commit changes when ready
|
||||
|
||||
## API Testing
|
||||
```bash
|
||||
# Test API endpoints via curl (in Git Bash or WSL)
|
||||
curl -X GET http://localhost:8000/api/valueset
|
||||
|
||||
# Test with authentication (requires JWT token)
|
||||
curl -X GET http://localhost:8000/api/patient -H "Authorization: Bearer YOUR_TOKEN"
|
||||
```
|
||||
135
.serena/memories/task_completion.md
Normal file
135
.serena/memories/task_completion.md
Normal file
@ -0,0 +1,135 @@
|
||||
# CLQMS Frontend - Task Completion Checklist
|
||||
|
||||
## When Completing a Task
|
||||
|
||||
### 1. Code Quality
|
||||
- [ ] Code follows project conventions (camelCase, 2-space indent, semicolons)
|
||||
- [ ] Components use Svelte 5 runes (`$state`, `$derived`, `$effect`, `$props`)
|
||||
- [ ] All exported functions have JSDoc comments
|
||||
- [ ] Imports are ordered correctly (Svelte, $lib, external)
|
||||
- [ ] No hardcoded values (use environment variables or constants)
|
||||
- [ ] Error handling is properly implemented
|
||||
- [ ] Loading states are shown for async operations
|
||||
- [ ] Toast notifications for user feedback
|
||||
|
||||
### 2. Testing (when test framework is configured)
|
||||
- [ ] Unit tests written for new functions
|
||||
- [ ] Component tests written for new components
|
||||
- [ ] All tests pass: `pnpm test`
|
||||
- [ ] Test coverage is adequate
|
||||
|
||||
### 3. Linting and Formatting (when configured)
|
||||
- [ ] Run linter: `pnpm run lint`
|
||||
- [ ] Fix any linting errors
|
||||
- [ ] Run formatter: `pnpm run format`
|
||||
- [ ] No linting or formatting errors
|
||||
|
||||
### 4. Build Verification
|
||||
- [ ] Production build succeeds: `pnpm run build`
|
||||
- [ ] No build errors or warnings
|
||||
- [ ] Preview build works: `pnpm run preview`
|
||||
- [ ] Application runs without console errors
|
||||
|
||||
### 5. Manual Testing
|
||||
- [ ] Feature works as expected in dev environment
|
||||
- [ ] Navigation works correctly
|
||||
- [ ] Forms validate properly
|
||||
- [ ] Error messages are clear
|
||||
- [ ] Loading states display correctly
|
||||
- [ ] Toast notifications appear for success/error
|
||||
- [ ] Responsive design works on mobile/tablet
|
||||
- [ ] Accessibility: keyboard navigation, ARIA labels
|
||||
|
||||
### 6. Documentation
|
||||
- [ ] API endpoints documented in comments
|
||||
- [ ] Complex logic explained in comments
|
||||
- [ ] New components documented
|
||||
- [ ] README updated (if needed)
|
||||
- [ ] Implementation plan updated (if applicable)
|
||||
|
||||
### 7. Security Considerations
|
||||
- [ ] No sensitive data exposed to client
|
||||
- [ ] API calls use proper authentication
|
||||
- [ ] User input is validated
|
||||
- [ ] XSS vulnerabilities checked
|
||||
- [ ] CSRF protection (if applicable)
|
||||
|
||||
### 8. Performance
|
||||
- [ ] No unnecessary re-renders
|
||||
- [ ] Large lists use pagination
|
||||
- [ ] Images are optimized (if applicable)
|
||||
- [ ] Bundle size impact is minimal
|
||||
|
||||
### 9. Browser Compatibility
|
||||
- [ ] Works in modern browsers (Chrome, Firefox, Edge, Safari)
|
||||
- [ ] Feature detection used for newer APIs (if needed)
|
||||
- [ ] Polyfills considered (if needed)
|
||||
|
||||
## Before Marking Task Complete
|
||||
|
||||
1. **Review Code**: Check all items in the checklist above
|
||||
2. **Test Thoroughly**: Manual testing of all new/modified features
|
||||
3. **Check Build**: Ensure production build succeeds
|
||||
4. **Run Tests**: Ensure all tests pass (when test framework is configured)
|
||||
5. **Run Linter**: Ensure no linting errors (when configured)
|
||||
|
||||
## Common Issues to Check
|
||||
|
||||
### API Issues
|
||||
- Check API endpoints match backend documentation
|
||||
- Verify request/response format
|
||||
- Check error handling for failed requests
|
||||
- Ensure 401 redirects to login work
|
||||
|
||||
### Svelte/Component Issues
|
||||
- Check reactivity with `$state` and `$derived`
|
||||
- Verify lifecycle hooks (`onMount`, `onDestroy`)
|
||||
- Check prop passing between components
|
||||
- Ensure event handlers work correctly
|
||||
|
||||
### Styling Issues
|
||||
- Check responsive design (mobile, tablet, desktop)
|
||||
- Verify DaisyUI component usage
|
||||
- Check color scheme consistency
|
||||
- Ensure accessibility (contrast, focus states)
|
||||
|
||||
### State Management Issues
|
||||
- Verify store updates trigger UI updates
|
||||
- Check localStorage handling (browser check)
|
||||
- Ensure auth state is managed correctly
|
||||
- Verify derived state calculations
|
||||
|
||||
## Git Commit Guidelines (if committing)
|
||||
|
||||
Follow conventional commits format:
|
||||
- `feat: add new feature`
|
||||
- `fix: fix bug`
|
||||
- `docs: update documentation`
|
||||
- `style: format code`
|
||||
- `refactor: refactor code`
|
||||
- `test: add tests`
|
||||
- `chore: maintenance tasks`
|
||||
|
||||
Examples:
|
||||
- `feat: add patient create form with validation`
|
||||
- `fix: handle API errors properly in patient list`
|
||||
- `docs: update API endpoint documentation`
|
||||
- `refactor: extract form handling logic to utility function`
|
||||
|
||||
## When Tests Are Not Available
|
||||
|
||||
Currently, no test framework is configured. Until tests are added:
|
||||
- Focus on manual testing
|
||||
- Test all user flows
|
||||
- Check edge cases (empty data, errors, network issues)
|
||||
- Verify error handling
|
||||
- Test responsive design
|
||||
|
||||
## Future: When Tests Are Added
|
||||
|
||||
Once Vitest and Playwright are configured:
|
||||
- Add unit tests for new functions
|
||||
- Add component tests for new components
|
||||
- Add E2E tests for user flows
|
||||
- Ensure test coverage is maintained
|
||||
- Run tests before marking task complete
|
||||
127
.serena/project.yml
Normal file
127
.serena/project.yml
Normal file
@ -0,0 +1,127 @@
|
||||
# the name by which the project can be referenced within Serena
|
||||
project_name: "clqms01-fe"
|
||||
|
||||
|
||||
# list of languages for which language servers are started; choose from:
|
||||
# al bash clojure cpp csharp
|
||||
# csharp_omnisharp dart elixir elm erlang
|
||||
# fortran fsharp go groovy haskell
|
||||
# java julia kotlin lua markdown
|
||||
# matlab nix pascal perl php
|
||||
# php_phpactor powershell python python_jedi r
|
||||
# rego ruby ruby_solargraph rust scala
|
||||
# swift terraform toml typescript typescript_vts
|
||||
# vue yaml zig
|
||||
# (This list may be outdated. For the current list, see values of Language enum here:
|
||||
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py
|
||||
# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
|
||||
# Note:
|
||||
# - For C, use cpp
|
||||
# - For JavaScript, use typescript
|
||||
# - For Free Pascal/Lazarus, use pascal
|
||||
# Special requirements:
|
||||
# Some languages require additional setup/installations.
|
||||
# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers
|
||||
# When using multiple languages, the first language server that supports a given file will be used for that file.
|
||||
# The first language is the default language and the respective language server will be used as a fallback.
|
||||
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
|
||||
languages:
|
||||
- typescript
|
||||
|
||||
# the encoding used by text files in the project
|
||||
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
|
||||
encoding: "utf-8"
|
||||
|
||||
# whether to use project's .gitignore files to ignore files
|
||||
ignore_all_files_in_gitignore: true
|
||||
|
||||
# list of additional paths to ignore in all projects
|
||||
# same syntax as gitignore, so you can use * and **
|
||||
ignored_paths: []
|
||||
|
||||
# whether the project is in read-only mode
|
||||
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
|
||||
# Added on 2025-04-18
|
||||
read_only: false
|
||||
|
||||
# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details.
|
||||
# Below is the complete list of tools for convenience.
|
||||
# To make sure you have the latest list of tools, and to view their descriptions,
|
||||
# execute `uv run scripts/print_tool_overview.py`.
|
||||
#
|
||||
# * `activate_project`: Activates a project by name.
|
||||
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
|
||||
# * `create_text_file`: Creates/overwrites a file in the project directory.
|
||||
# * `delete_lines`: Deletes a range of lines within a file.
|
||||
# * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
|
||||
# * `execute_shell_command`: Executes a shell command.
|
||||
# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
|
||||
# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
|
||||
# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
|
||||
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
|
||||
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
|
||||
# * `initial_instructions`: Gets the initial instructions for the current project.
|
||||
# Should only be used in settings where the system prompt cannot be set,
|
||||
# e.g. in clients you have no control over, like Claude Desktop.
|
||||
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
|
||||
# * `insert_at_line`: Inserts content at a given line in a file.
|
||||
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
|
||||
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
|
||||
# * `list_memories`: Lists memories in Serena's project-specific memory store.
|
||||
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
|
||||
# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
|
||||
# * `read_file`: Reads a file within the project directory.
|
||||
# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
|
||||
# * `remove_project`: Removes a project from the Serena configuration.
|
||||
# * `replace_lines`: Replaces a range of lines within a file with new content.
|
||||
# * `replace_symbol_body`: Replaces the full definition of a symbol.
|
||||
# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
|
||||
# * `search_for_pattern`: Performs a search for a pattern in the project.
|
||||
# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
|
||||
# * `switch_modes`: Activates modes by providing a list of their names
|
||||
# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
|
||||
# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
|
||||
# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
|
||||
# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
|
||||
excluded_tools: []
|
||||
|
||||
# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default)
|
||||
included_optional_tools: []
|
||||
|
||||
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
|
||||
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
|
||||
fixed_tools: []
|
||||
|
||||
# list of mode names to that are always to be included in the set of active modes
|
||||
# The full set of modes to be activated is base_modes + default_modes.
|
||||
# If the setting is undefined, the base_modes from the global configuration (serena_config.yml) apply.
|
||||
# Otherwise, this setting overrides the global configuration.
|
||||
# Set this to [] to disable base modes for this project.
|
||||
# Set this to a list of mode names to always include the respective modes for this project.
|
||||
base_modes:
|
||||
|
||||
# list of mode names that are to be activated by default.
|
||||
# The full set of modes to be activated is base_modes + default_modes.
|
||||
# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply.
|
||||
# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
|
||||
# This setting can, in turn, be overridden by CLI parameters (--mode).
|
||||
default_modes:
|
||||
|
||||
# initial prompt for the project. It will always be given to the LLM upon activating the project
|
||||
# (contrary to the memories, which are loaded on demand).
|
||||
initial_prompt: ""
|
||||
|
||||
# override of the corresponding setting in serena_config.yml, see the documentation there.
|
||||
# If null or missing, the value from the global config is used.
|
||||
symbol_info_budget:
|
||||
|
||||
# The language backend to use for this project.
|
||||
# If not set, the global setting from serena_config.yml is used.
|
||||
# Valid values: LSP, JetBrains
|
||||
# Note: the backend is fixed at startup. If a project with a different backend
|
||||
# is activated post-init, an error will be returned.
|
||||
language_backend:
|
||||
|
||||
# list of regex patterns which, when matched, mark a memory entry as read‑only.
|
||||
# Extends the list from the global configuration, merging the two lists.
|
||||
read_only_memory_patterns: []
|
||||
12
TODO.md
12
TODO.md
@ -1,7 +1,7 @@
|
||||
# TODO
|
||||
# its MVP so Keep It Simple STUPID
|
||||
|
||||
## Backend
|
||||
- [ ] Work on patvisit backend
|
||||
|
||||
## Frontend
|
||||
- [ ] Visits index: add search parameter, remove type, show visitdate
|
||||
### done
|
||||
- patient page -> add edit patient button on patient list
|
||||
- patient page -> add view order modal
|
||||
- visit page -> on visit modal, show visitid when create
|
||||
- visit page -> on visit modal, make uniform design when create and edit
|
||||
@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-03-08
|
||||
@ -0,0 +1,74 @@
|
||||
# Backend Implementation - Quick Reference
|
||||
|
||||
## Files to Create
|
||||
|
||||
1. **app/Controllers/User/UserController.php**
|
||||
- Copy from: `code-templates/UserController.php`
|
||||
- Creates: Full CRUD for users
|
||||
|
||||
2. **app/Models/User/UserModel.php**
|
||||
- Copy from: `code-templates/UserModel.php`
|
||||
- Creates: User database model
|
||||
|
||||
## Files to Modify
|
||||
|
||||
3. **app/Controllers/Specimen/SpecimenController.php**
|
||||
- Add method from: `code-templates/SpecimenController-delete-method.php`
|
||||
- Adds: Delete specimen functionality
|
||||
|
||||
4. **app/Config/Routes.php**
|
||||
- Add routes from: `code-templates/Routes-additions.php`
|
||||
- Adds: User routes + Specimen delete route
|
||||
|
||||
## Database Migration
|
||||
|
||||
Run this SQL if `users` table doesn't exist:
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
UserID INT AUTO_INCREMENT PRIMARY KEY,
|
||||
Username VARCHAR(50) NOT NULL UNIQUE,
|
||||
Email VARCHAR(100) NOT NULL,
|
||||
Name VARCHAR(100),
|
||||
Role VARCHAR(50),
|
||||
Department VARCHAR(100),
|
||||
IsActive BOOLEAN DEFAULT TRUE,
|
||||
CreatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UpdatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
DelDate TIMESTAMP NULL,
|
||||
INDEX idx_username (Username),
|
||||
INDEX idx_email (Email)
|
||||
);
|
||||
```
|
||||
|
||||
## Testing Commands
|
||||
|
||||
```bash
|
||||
# Specimen Delete
|
||||
curl -X DELETE http://localhost:8000/api/specimen/1
|
||||
|
||||
# Users API
|
||||
curl http://localhost:8000/api/users
|
||||
curl http://localhost:8000/api/users/1
|
||||
curl -X POST http://localhost:8000/api/users \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"Username":"test","Email":"test@test.com","Name":"Test User"}'
|
||||
curl -X PATCH http://localhost:8000/api/users \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"UserID":1,"Name":"Updated"}'
|
||||
curl -X DELETE http://localhost:8000/api/users/1
|
||||
```
|
||||
|
||||
## Timeline Estimate
|
||||
|
||||
- **Specimen Delete**: 15 minutes (just add route + method)
|
||||
- **User API**: 1-2 hours (new controller + model + routes)
|
||||
- **Testing**: 30 minutes
|
||||
|
||||
**Total: ~2-3 hours**
|
||||
|
||||
## Questions?
|
||||
|
||||
See the detailed specs:
|
||||
- `specimen-delete.md` - Full specimen delete specification
|
||||
- `user-api.md` - Complete user API specification with examples
|
||||
@ -0,0 +1,55 @@
|
||||
# Backend API Requirements
|
||||
|
||||
This folder contains specifications for backend changes needed to support the frontend features.
|
||||
|
||||
## Quick Summary
|
||||
|
||||
| Feature | Status | Priority | Files to Create/Modify |
|
||||
|---------|--------|----------|----------------------|
|
||||
| Specimen Delete | Missing | High | Routes.php, SpecimenController.php |
|
||||
| User CRUD | Not Implemented | High | UserController.php, UserModel.php, Routes.php |
|
||||
|
||||
## Directory Structure to Create
|
||||
|
||||
```
|
||||
app/
|
||||
├── Config/
|
||||
│ └── Routes.php # MODIFY - Add new routes
|
||||
├── Controllers/
|
||||
│ └── Specimen/
|
||||
│ └── SpecimenController.php # MODIFY - Add delete method
|
||||
│ └── User/ # CREATE
|
||||
│ └── UserController.php # CREATE - Full CRUD
|
||||
├── Models/
|
||||
│ └── User/ # CREATE
|
||||
│ └── UserModel.php # CREATE - User database model
|
||||
```
|
||||
|
||||
## Files
|
||||
|
||||
1. **specimen-delete.md** - Specimen delete endpoint specification
|
||||
2. **user-api.md** - Complete User CRUD API specification
|
||||
3. **code-templates/** - Ready-to-use code templates
|
||||
- UserController.php
|
||||
- UserModel.php
|
||||
- Routes-additions.php
|
||||
|
||||
## Testing Checklist
|
||||
|
||||
After implementation, verify these endpoints work:
|
||||
|
||||
```bash
|
||||
# Specimen Delete
|
||||
curl -X DELETE http://localhost:8000/api/specimen/123
|
||||
|
||||
# User CRUD
|
||||
curl http://localhost:8000/api/users
|
||||
curl http://localhost:8000/api/users/1
|
||||
curl -X POST http://localhost:8000/api/users -H "Content-Type: application/json" -d '{"username":"test","email":"test@test.com"}'
|
||||
curl -X PATCH http://localhost:8000/api/users -H "Content-Type: application/json" -d '{"UserID":1,"username":"updated"}'
|
||||
curl -X DELETE http://localhost:8000/api/users/1
|
||||
```
|
||||
|
||||
## Questions?
|
||||
|
||||
Contact the frontend team if you need clarification on data structures or expected responses.
|
||||
@ -0,0 +1,42 @@
|
||||
// ============================================================================
|
||||
// ADD THESE ROUTES TO: app/Config/Routes.php
|
||||
// ============================================================================
|
||||
|
||||
// Add this inside the '$routes->group('api', function ($routes) {' section
|
||||
// Preferably after the Organization routes and before the Specimen routes
|
||||
|
||||
// Users Management
|
||||
$routes->group('users', function ($routes) {
|
||||
$routes->get('/', 'User\UserController::index');
|
||||
$routes->get('(:num)', 'User\UserController::show/$1');
|
||||
$routes->post('/', 'User\UserController::create');
|
||||
$routes->patch('/', 'User\UserController::update');
|
||||
$routes->delete('(:num)', 'User\UserController::delete/$1');
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// SPECIMEN DELETE ROUTE
|
||||
// Add this INSIDE the existing specimen group (around line 256-296)
|
||||
// ============================================================================
|
||||
|
||||
$routes->group('specimen', function ($routes) {
|
||||
// ... existing routes ...
|
||||
|
||||
// ADD THIS LINE:
|
||||
$routes->delete('(:num)', 'Specimen\SpecimenController::delete/$1');
|
||||
|
||||
// ... rest of existing routes ...
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// COMPLETE EXAMPLE - Users route placement in context
|
||||
// ============================================================================
|
||||
|
||||
// Master Data section (after Equipment, before Specimen)
|
||||
$routes->group('users', function ($routes) {
|
||||
$routes->get('/', 'User\UserController::index');
|
||||
$routes->get('(:num)', 'User\UserController::show/$1');
|
||||
$routes->post('/', 'User\UserController::create');
|
||||
$routes->patch('/', 'User\UserController::update');
|
||||
$routes->delete('(:num)', 'User\UserController::delete/$1');
|
||||
});
|
||||
@ -0,0 +1,49 @@
|
||||
// ============================================================================
|
||||
// ADD THIS METHOD TO: app/Controllers/Specimen/SpecimenController.php
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Delete a specimen (soft delete)
|
||||
* DELETE /api/specimen/(:num)
|
||||
*/
|
||||
public function delete($id) {
|
||||
try {
|
||||
// Check if specimen exists
|
||||
$specimen = $this->model->where('SID', $id)->first();
|
||||
|
||||
if (empty($specimen)) {
|
||||
return $this->respond([
|
||||
'status' => 'failed',
|
||||
'message' => 'Specimen not found',
|
||||
'data' => null
|
||||
], 404);
|
||||
}
|
||||
|
||||
// Perform soft delete (set DelDate)
|
||||
$deleted = $this->model->update($id, [
|
||||
'DelDate' => date('Y-m-d H:i:s')
|
||||
]);
|
||||
|
||||
if (!$deleted) {
|
||||
return $this->respond([
|
||||
'status' => 'failed',
|
||||
'message' => 'Failed to delete specimen',
|
||||
'data' => null
|
||||
], 500);
|
||||
}
|
||||
|
||||
return $this->respond([
|
||||
'status' => 'success',
|
||||
'message' => 'Specimen deleted successfully',
|
||||
'data' => ['SID' => $id]
|
||||
], 200);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
log_message('error', 'SpecimenController::delete error: ' . $e->getMessage());
|
||||
return $this->respond([
|
||||
'status' => 'failed',
|
||||
'message' => 'Failed to delete specimen',
|
||||
'data' => null
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,290 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controllers\User;
|
||||
|
||||
use App\Controllers\BaseController;
|
||||
use App\Models\User\UserModel;
|
||||
|
||||
/**
|
||||
* User Management Controller
|
||||
* Handles CRUD operations for users
|
||||
*/
|
||||
class UserController extends BaseController
|
||||
{
|
||||
protected $model;
|
||||
protected $db;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->db = \Config\Database::connect();
|
||||
$this->model = new UserModel();
|
||||
}
|
||||
|
||||
/**
|
||||
* List users with pagination and search
|
||||
* GET /api/users?page=1&per_page=20&search=term
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
try {
|
||||
$page = (int)($this->request->getGet('page') ?? 1);
|
||||
$perPage = (int)($this->request->getGet('per_page') ?? 20);
|
||||
$search = $this->request->getGet('search');
|
||||
|
||||
// Build query
|
||||
$builder = $this->model->where('DelDate', null);
|
||||
|
||||
// Apply search if provided
|
||||
if ($search) {
|
||||
$builder->groupStart()
|
||||
->like('Username', $search)
|
||||
->orLike('Email', $search)
|
||||
->orLike('Name', $search)
|
||||
->groupEnd();
|
||||
}
|
||||
|
||||
// Get total count for pagination
|
||||
$total = $builder->countAllResults(false);
|
||||
|
||||
// Get paginated results
|
||||
$users = $builder
|
||||
->orderBy('UserID', 'DESC')
|
||||
->limit($perPage, ($page - 1) * $perPage)
|
||||
->findAll();
|
||||
|
||||
return $this->respond([
|
||||
'status' => 'success',
|
||||
'message' => 'Users retrieved successfully',
|
||||
'data' => [
|
||||
'users' => $users,
|
||||
'pagination' => [
|
||||
'current_page' => $page,
|
||||
'per_page' => $perPage,
|
||||
'total' => $total,
|
||||
'total_pages' => ceil($total / $perPage)
|
||||
]
|
||||
]
|
||||
], 200);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
log_message('error', 'UserController::index error: ' . $e->getMessage());
|
||||
return $this->respond([
|
||||
'status' => 'failed',
|
||||
'message' => 'Failed to retrieve users',
|
||||
'data' => null
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get single user by ID
|
||||
* GET /api/users/(:num)
|
||||
*/
|
||||
public function show($id)
|
||||
{
|
||||
try {
|
||||
$user = $this->model->where('UserID', $id)
|
||||
->where('DelDate', null)
|
||||
->first();
|
||||
|
||||
if (empty($user)) {
|
||||
return $this->respond([
|
||||
'status' => 'failed',
|
||||
'message' => 'User not found',
|
||||
'data' => null
|
||||
], 404);
|
||||
}
|
||||
|
||||
return $this->respond([
|
||||
'status' => 'success',
|
||||
'message' => 'User retrieved successfully',
|
||||
'data' => $user
|
||||
], 200);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
log_message('error', 'UserController::show error: ' . $e->getMessage());
|
||||
return $this->respond([
|
||||
'status' => 'failed',
|
||||
'message' => 'Failed to retrieve user',
|
||||
'data' => null
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new user
|
||||
* POST /api/users
|
||||
*/
|
||||
public function create()
|
||||
{
|
||||
try {
|
||||
$data = $this->request->getJSON(true);
|
||||
|
||||
// Validation rules
|
||||
$rules = [
|
||||
'Username' => 'required|min_length[3]|max_length[50]|is_unique[users.Username]',
|
||||
'Email' => 'required|valid_email|is_unique[users.Email]',
|
||||
];
|
||||
|
||||
if (!$this->validateData($data, $rules)) {
|
||||
return $this->respond([
|
||||
'status' => 'failed',
|
||||
'message' => 'Validation failed',
|
||||
'data' => $this->validator->getErrors()
|
||||
], 400);
|
||||
}
|
||||
|
||||
// Set default values
|
||||
$data['IsActive'] = $data['IsActive'] ?? true;
|
||||
$data['CreatedAt'] = date('Y-m-d H:i:s');
|
||||
|
||||
$userId = $this->model->insert($data);
|
||||
|
||||
if (!$userId) {
|
||||
return $this->respond([
|
||||
'status' => 'failed',
|
||||
'message' => 'Failed to create user',
|
||||
'data' => null
|
||||
], 500);
|
||||
}
|
||||
|
||||
return $this->respond([
|
||||
'status' => 'success',
|
||||
'message' => 'User created successfully',
|
||||
'data' => [
|
||||
'UserID' => $userId,
|
||||
'Username' => $data['Username'],
|
||||
'Email' => $data['Email']
|
||||
]
|
||||
], 201);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
log_message('error', 'UserController::create error: ' . $e->getMessage());
|
||||
return $this->respond([
|
||||
'status' => 'failed',
|
||||
'message' => 'Failed to create user',
|
||||
'data' => null
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update existing user
|
||||
* PATCH /api/users
|
||||
*/
|
||||
public function update()
|
||||
{
|
||||
try {
|
||||
$data = $this->request->getJSON(true);
|
||||
|
||||
if (empty($data['UserID'])) {
|
||||
return $this->respond([
|
||||
'status' => 'failed',
|
||||
'message' => 'UserID is required',
|
||||
'data' => null
|
||||
], 400);
|
||||
}
|
||||
|
||||
$userId = $data['UserID'];
|
||||
|
||||
// Check if user exists
|
||||
$user = $this->model->where('UserID', $userId)
|
||||
->where('DelDate', null)
|
||||
->first();
|
||||
|
||||
if (empty($user)) {
|
||||
return $this->respond([
|
||||
'status' => 'failed',
|
||||
'message' => 'User not found',
|
||||
'data' => null
|
||||
], 404);
|
||||
}
|
||||
|
||||
// Remove UserID from data array
|
||||
unset($data['UserID']);
|
||||
|
||||
// Don't allow updating these fields
|
||||
unset($data['CreatedAt']);
|
||||
unset($data['Username']); // Username should not change
|
||||
|
||||
$data['UpdatedAt'] = date('Y-m-d H:i:s');
|
||||
|
||||
$updated = $this->model->update($userId, $data);
|
||||
|
||||
if (!$updated) {
|
||||
return $this->respond([
|
||||
'status' => 'failed',
|
||||
'message' => 'Failed to update user',
|
||||
'data' => null
|
||||
], 500);
|
||||
}
|
||||
|
||||
return $this->respond([
|
||||
'status' => 'success',
|
||||
'message' => 'User updated successfully',
|
||||
'data' => [
|
||||
'UserID' => $userId,
|
||||
'updated_fields' => array_keys($data)
|
||||
]
|
||||
], 200);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
log_message('error', 'UserController::update error: ' . $e->getMessage());
|
||||
return $this->respond([
|
||||
'status' => 'failed',
|
||||
'message' => 'Failed to update user',
|
||||
'data' => null
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete user (soft delete)
|
||||
* DELETE /api/users/(:num)
|
||||
*/
|
||||
public function delete($id)
|
||||
{
|
||||
try {
|
||||
// Check if user exists
|
||||
$user = $this->model->where('UserID', $id)
|
||||
->where('DelDate', null)
|
||||
->first();
|
||||
|
||||
if (empty($user)) {
|
||||
return $this->respond([
|
||||
'status' => 'failed',
|
||||
'message' => 'User not found',
|
||||
'data' => null
|
||||
], 404);
|
||||
}
|
||||
|
||||
// Soft delete by setting DelDate
|
||||
$deleted = $this->model->update($id, [
|
||||
'DelDate' => date('Y-m-d H:i:s'),
|
||||
'IsActive' => false
|
||||
]);
|
||||
|
||||
if (!$deleted) {
|
||||
return $this->respond([
|
||||
'status' => 'failed',
|
||||
'message' => 'Failed to delete user',
|
||||
'data' => null
|
||||
], 500);
|
||||
}
|
||||
|
||||
return $this->respond([
|
||||
'status' => 'success',
|
||||
'message' => 'User deleted successfully',
|
||||
'data' => ['UserID' => $id]
|
||||
], 200);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
log_message('error', 'UserController::delete error: ' . $e->getMessage());
|
||||
return $this->respond([
|
||||
'status' => 'failed',
|
||||
'message' => 'Failed to delete user',
|
||||
'data' => null
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,138 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\User;
|
||||
|
||||
use CodeIgniter\Model;
|
||||
|
||||
/**
|
||||
* User Model
|
||||
* Handles database operations for users
|
||||
*/
|
||||
class UserModel extends Model
|
||||
{
|
||||
protected $table = 'users';
|
||||
protected $primaryKey = 'UserID';
|
||||
|
||||
// Allow all fields to be mass-assigned
|
||||
protected $allowedFields = [
|
||||
'Username',
|
||||
'Email',
|
||||
'Name',
|
||||
'Role',
|
||||
'Department',
|
||||
'IsActive',
|
||||
'CreatedAt',
|
||||
'UpdatedAt',
|
||||
'DelDate'
|
||||
];
|
||||
|
||||
// Use timestamps (disabled, we handle manually for consistency)
|
||||
protected $useTimestamps = false;
|
||||
|
||||
// Validation rules
|
||||
protected $validationRules = [
|
||||
'Username' => 'required|min_length[3]|max_length[50]',
|
||||
'Email' => 'required|valid_email|max_length[100]',
|
||||
];
|
||||
|
||||
protected $validationMessages = [
|
||||
'Username' => [
|
||||
'required' => 'Username is required',
|
||||
'min_length' => 'Username must be at least 3 characters',
|
||||
'max_length' => 'Username cannot exceed 50 characters',
|
||||
],
|
||||
'Email' => [
|
||||
'required' => 'Email is required',
|
||||
'valid_email' => 'Please provide a valid email address',
|
||||
'max_length' => 'Email cannot exceed 100 characters',
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
* Get active users only
|
||||
*/
|
||||
public function getActive()
|
||||
{
|
||||
return $this->where('DelDate', null)
|
||||
->where('IsActive', true)
|
||||
->findAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Find user by username
|
||||
*/
|
||||
public function findByUsername($username)
|
||||
{
|
||||
return $this->where('Username', $username)
|
||||
->where('DelDate', null)
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Find user by email
|
||||
*/
|
||||
public function findByEmail($email)
|
||||
{
|
||||
return $this->where('Email', $email)
|
||||
->where('DelDate', null)
|
||||
->first();
|
||||
}
|
||||
|
||||
/**
|
||||
* Search users by name, username, or email
|
||||
*/
|
||||
public function search($term)
|
||||
{
|
||||
return $this->where('DelDate', null)
|
||||
->groupStart()
|
||||
->like('Username', $term)
|
||||
->orLike('Email', $term)
|
||||
->orLike('Name', $term)
|
||||
->groupEnd()
|
||||
->findAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get users by role
|
||||
*/
|
||||
public function getByRole($role)
|
||||
{
|
||||
return $this->where('Role', $role)
|
||||
->where('DelDate', null)
|
||||
->where('IsActive', true)
|
||||
->findAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get users by department
|
||||
*/
|
||||
public function getByDepartment($department)
|
||||
{
|
||||
return $this->where('Department', $department)
|
||||
->where('DelDate', null)
|
||||
->where('IsActive', true)
|
||||
->findAll();
|
||||
}
|
||||
|
||||
/**
|
||||
* Soft delete user
|
||||
*/
|
||||
public function softDelete($id)
|
||||
{
|
||||
return $this->update($id, [
|
||||
'DelDate' => date('Y-m-d H:i:s'),
|
||||
'IsActive' => false
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore soft-deleted user
|
||||
*/
|
||||
public function restore($id)
|
||||
{
|
||||
return $this->update($id, [
|
||||
'DelDate' => null,
|
||||
'IsActive' => true
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,119 @@
|
||||
# Specimen Delete Endpoint
|
||||
|
||||
## Overview
|
||||
Add DELETE endpoint for specimens to complete CRUD operations.
|
||||
|
||||
## Current State
|
||||
- GET /api/specimen - List specimens ✅
|
||||
- GET /api/specimen/(:num) - Get single specimen ✅
|
||||
- POST /api/specimen - Create specimen ✅
|
||||
- PATCH /api/specimen - Update specimen ✅
|
||||
- DELETE /api/specimen/(:num) - **MISSING** ❌
|
||||
|
||||
## Requirements
|
||||
|
||||
### Route
|
||||
|
||||
Add to `app/Config/Routes.php` in the specimen group:
|
||||
|
||||
```php
|
||||
$routes->group('specimen', function ($routes) {
|
||||
// ... existing routes ...
|
||||
$routes->delete('(:num)', 'Specimen\SpecimenController::delete/$1');
|
||||
});
|
||||
```
|
||||
|
||||
### Controller Method
|
||||
|
||||
Add to `app/Controllers/Specimen/SpecimenController.php`:
|
||||
|
||||
```php
|
||||
/**
|
||||
* Delete a specimen (soft delete)
|
||||
* DELETE /api/specimen/(:num)
|
||||
*/
|
||||
public function delete($id) {
|
||||
try {
|
||||
// Check if specimen exists
|
||||
$specimen = $this->model->where('SID', $id)->first();
|
||||
|
||||
if (empty($specimen)) {
|
||||
return $this->respond([
|
||||
'status' => 'failed',
|
||||
'message' => 'Specimen not found',
|
||||
'data' => null
|
||||
], 404);
|
||||
}
|
||||
|
||||
// Perform soft delete (set DelDate)
|
||||
$deleted = $this->model->update($id, [
|
||||
'DelDate' => date('Y-m-d H:i:s')
|
||||
]);
|
||||
|
||||
if (!$deleted) {
|
||||
return $this->respond([
|
||||
'status' => 'failed',
|
||||
'message' => 'Failed to delete specimen',
|
||||
'data' => null
|
||||
], 500);
|
||||
}
|
||||
|
||||
return $this->respond([
|
||||
'status' => 'success',
|
||||
'message' => 'Specimen deleted successfully',
|
||||
'data' => ['SID' => $id]
|
||||
], 200);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
log_message('error', 'SpecimenController::delete error: ' . $e->getMessage());
|
||||
return $this->respond([
|
||||
'status' => 'failed',
|
||||
'message' => 'Failed to delete specimen',
|
||||
'data' => null
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Expected Request/Response
|
||||
|
||||
### Request
|
||||
```http
|
||||
DELETE /api/specimen/123 HTTP/1.1
|
||||
```
|
||||
|
||||
### Success Response (200)
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Specimen deleted successfully",
|
||||
"data": {
|
||||
"SID": 123
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Not Found Response (404)
|
||||
```json
|
||||
{
|
||||
"status": "failed",
|
||||
"message": "Specimen not found",
|
||||
"data": null
|
||||
}
|
||||
```
|
||||
|
||||
### Error Response (500)
|
||||
```json
|
||||
{
|
||||
"status": "failed",
|
||||
"message": "Failed to delete specimen",
|
||||
"data": null
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
1. **Use soft delete**: Set `DelDate` field instead of hard delete
|
||||
2. **Check existence first**: Return 404 if specimen doesn't exist
|
||||
3. **Use SID as identifier**: The specimen ID field is `SID`, not `id`
|
||||
4. **Follow existing patterns**: Match style of other delete methods in codebase
|
||||
@ -0,0 +1,243 @@
|
||||
# User Management API
|
||||
|
||||
## Overview
|
||||
Create a complete User CRUD API for the user management page.
|
||||
|
||||
## Required Endpoints
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| GET | /api/users | List all users (with pagination) |
|
||||
| GET | /api/users/(:num) | Get single user by ID |
|
||||
| POST | /api/users | Create new user |
|
||||
| PATCH | /api/users | Update existing user |
|
||||
| DELETE | /api/users/(:num) | Delete user |
|
||||
|
||||
## Database Schema
|
||||
|
||||
The User model should use the existing `users` table (or create if doesn't exist):
|
||||
|
||||
```sql
|
||||
-- If table doesn't exist, create it:
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
UserID INT AUTO_INCREMENT PRIMARY KEY,
|
||||
Username VARCHAR(50) NOT NULL UNIQUE,
|
||||
Email VARCHAR(100) NOT NULL,
|
||||
Name VARCHAR(100),
|
||||
Role VARCHAR(50),
|
||||
Department VARCHAR(100),
|
||||
IsActive BOOLEAN DEFAULT TRUE,
|
||||
CreatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UpdatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
DelDate TIMESTAMP NULL,
|
||||
INDEX idx_username (Username),
|
||||
INDEX idx_email (Email)
|
||||
);
|
||||
```
|
||||
|
||||
## User Fields
|
||||
|
||||
### Required Fields
|
||||
- `Username` - Unique login username
|
||||
- `Email` - User email address
|
||||
|
||||
### Optional Fields
|
||||
- `Name` - Full name
|
||||
- `Role` - User role (admin, technician, doctor, etc.)
|
||||
- `Department` - Department name
|
||||
- `IsActive` - Whether user is active
|
||||
|
||||
## API Specifications
|
||||
|
||||
### 1. List Users
|
||||
|
||||
**Request:**
|
||||
```http
|
||||
GET /api/users?page=1&per_page=20&search=john HTTP/1.1
|
||||
```
|
||||
|
||||
**Query Parameters:**
|
||||
- `page` - Page number (default: 1)
|
||||
- `per_page` - Items per page (default: 20)
|
||||
- `search` - Search term for username/email/name (optional)
|
||||
|
||||
**Success Response (200):**
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "Users retrieved successfully",
|
||||
"data": {
|
||||
"users": [
|
||||
{
|
||||
"UserID": 1,
|
||||
"Username": "john.doe",
|
||||
"Email": "john@hospital.com",
|
||||
"Name": "John Doe",
|
||||
"Role": "technician",
|
||||
"Department": "Laboratory",
|
||||
"IsActive": true,
|
||||
"CreatedAt": "2024-01-15 10:30:00"
|
||||
}
|
||||
],
|
||||
"pagination": {
|
||||
"current_page": 1,
|
||||
"per_page": 20,
|
||||
"total": 150,
|
||||
"total_pages": 8
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Get Single User
|
||||
|
||||
**Request:**
|
||||
```http
|
||||
GET /api/users/1 HTTP/1.1
|
||||
```
|
||||
|
||||
**Success Response (200):**
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "User retrieved successfully",
|
||||
"data": {
|
||||
"UserID": 1,
|
||||
"Username": "john.doe",
|
||||
"Email": "john@hospital.com",
|
||||
"Name": "John Doe",
|
||||
"Role": "technician",
|
||||
"Department": "Laboratory",
|
||||
"IsActive": true,
|
||||
"CreatedAt": "2024-01-15 10:30:00",
|
||||
"UpdatedAt": "2024-01-15 10:30:00"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Not Found Response (404):**
|
||||
```json
|
||||
{
|
||||
"status": "failed",
|
||||
"message": "User not found",
|
||||
"data": null
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Create User
|
||||
|
||||
**Request:**
|
||||
```http
|
||||
POST /api/users HTTP/1.1
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"Username": "jane.smith",
|
||||
"Email": "jane@hospital.com",
|
||||
"Name": "Jane Smith",
|
||||
"Role": "doctor",
|
||||
"Department": "Pathology"
|
||||
}
|
||||
```
|
||||
|
||||
**Success Response (201):**
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "User created successfully",
|
||||
"data": {
|
||||
"UserID": 2,
|
||||
"Username": "jane.smith",
|
||||
"Email": "jane@hospital.com"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Validation Error Response (400):**
|
||||
```json
|
||||
{
|
||||
"status": "failed",
|
||||
"message": "Validation failed",
|
||||
"data": {
|
||||
"Username": "Username is required",
|
||||
"Email": "Email is required"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Update User
|
||||
|
||||
**Request:**
|
||||
```http
|
||||
PATCH /api/users HTTP/1.1
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"UserID": 1,
|
||||
"Name": "John Doe Updated",
|
||||
"Role": "senior_technician"
|
||||
}
|
||||
```
|
||||
|
||||
**Success Response (200):**
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "User updated successfully",
|
||||
"data": {
|
||||
"UserID": 1,
|
||||
"Name": "John Doe Updated",
|
||||
"Role": "senior_technician"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Delete User
|
||||
|
||||
**Request:**
|
||||
```http
|
||||
DELETE /api/users/1 HTTP/1.1
|
||||
```
|
||||
|
||||
**Success Response (200):**
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"message": "User deleted successfully",
|
||||
"data": {
|
||||
"UserID": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Implementation Files
|
||||
|
||||
See `code-templates/` folder for ready-to-use code:
|
||||
- `UserController.php` - Complete controller implementation
|
||||
- `UserModel.php` - Database model
|
||||
- `Routes-additions.php` - Routes to add to Routes.php
|
||||
|
||||
## Testing
|
||||
|
||||
After implementation, test with:
|
||||
|
||||
```bash
|
||||
# List users
|
||||
curl http://localhost:8000/api/users
|
||||
|
||||
# Get single user
|
||||
curl http://localhost:8000/api/users/1
|
||||
|
||||
# Create user
|
||||
curl -X POST http://localhost:8000/api/users \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"Username":"test","Email":"test@test.com","Name":"Test User"}'
|
||||
|
||||
# Update user
|
||||
curl -X PATCH http://localhost:8000/api/users \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"UserID":1,"Name":"Updated Name"}'
|
||||
|
||||
# Delete user
|
||||
curl -X DELETE http://localhost:8000/api/users/1
|
||||
```
|
||||
165
openspec/changes/complete-backend-todos-and-core-pages/design.md
Normal file
165
openspec/changes/complete-backend-todos-and-core-pages/design.md
Normal file
@ -0,0 +1,165 @@
|
||||
## Context
|
||||
|
||||
The CLQMS frontend currently has several incomplete features marked with TODO comments:
|
||||
|
||||
1. **Patient Delete**: The `deletePatient()` API exists but isn't wired up in the UI
|
||||
2. **Order Detail**: Orders can only be viewed as a list item, with no detail modal
|
||||
3. **Barcode Printing**: Referenced in UI but not implemented
|
||||
4. **TestMap Delete**: Blocked by API limitation (missing TestMapID in list response)
|
||||
|
||||
Additionally, the sidebar links to two pages that don't exist:
|
||||
- **Specimens**: Critical for complete lab workflow (patient → visit → order → specimen → results)
|
||||
- **Users**: Essential for system administration
|
||||
|
||||
This change addresses all these gaps to provide a complete laboratory management experience.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Wire up patient delete functionality
|
||||
- Create order detail view modal
|
||||
- Implement specimen management page with full CRUD
|
||||
- Implement user management page with full CRUD
|
||||
- Prepare barcode printing infrastructure
|
||||
- Document TestMap delete limitation
|
||||
|
||||
**Non-Goals:**
|
||||
- Full barcode generation library integration (out of scope, preparing structure only)
|
||||
- Backend API changes for TestMap (document limitation only)
|
||||
- Role-based access control (reuse existing auth patterns)
|
||||
- Advanced specimen tracking (batch, location) - basic CRUD only
|
||||
|
||||
## Backend API Requirements
|
||||
|
||||
### Specimen API (clqms01-be)
|
||||
**Current Status:** CRU exists, missing DELETE
|
||||
|
||||
Required additions:
|
||||
```php
|
||||
// Add to app/Config/Routes.php
|
||||
$routes->delete('(:num)', 'Specimen\SpecimenController::delete/$1');
|
||||
|
||||
// Add to SpecimenController
|
||||
public function delete($id) {
|
||||
// Soft delete specimen
|
||||
}
|
||||
```
|
||||
|
||||
### User API (clqms01-be)
|
||||
**Current Status:** Not implemented
|
||||
|
||||
Required new files:
|
||||
```
|
||||
app/
|
||||
├── Controllers/
|
||||
│ └── User/
|
||||
│ └── UserController.php # CRUD operations
|
||||
├── Models/
|
||||
│ └── User/
|
||||
│ └── UserModel.php # Database model
|
||||
└── Config/Routes.php # Add user routes
|
||||
```
|
||||
|
||||
Required endpoints:
|
||||
- `GET /api/users` - List with pagination
|
||||
- `GET /api/users/(:num)` - Get single user
|
||||
- `POST /api/users` - Create user
|
||||
- `PATCH /api/users` - Update user
|
||||
- `DELETE /api/users/(:num)` - Delete user
|
||||
|
||||
## Decisions
|
||||
|
||||
### Decision: Use Existing Patterns
|
||||
**Rationale**: Consistency with existing codebase reduces cognitive load and maintenance burden.
|
||||
|
||||
All new pages follow existing patterns:
|
||||
- Search/filter pattern from Patients page
|
||||
- Modal forms from Test Management
|
||||
- API structure from existing `/lib/api/*.js` files
|
||||
- DaisyUI + Tailwind styling consistent with current UI
|
||||
|
||||
### Decision: Barcode Printing - Stub First
|
||||
**Rationale**: Barcode libraries (jsbarcode, qrcode) add dependencies. Better to prepare structure first, then integrate library in follow-up.
|
||||
|
||||
Current implementation:
|
||||
- Print button triggers stub function
|
||||
- Opens print dialog with formatted HTML
|
||||
- Placeholder text indicates "coming soon"
|
||||
|
||||
Future enhancement:
|
||||
- Add jsbarcode or similar library
|
||||
- Generate actual barcodes
|
||||
- Keep same print dialog structure
|
||||
|
||||
### Decision: Specimen Page Structure
|
||||
**Rationale**: Specimens bridge orders and results - need to show relationships.
|
||||
|
||||
Page layout:
|
||||
- Search/filter bar (specimen type, status, date)
|
||||
- Data table with key columns
|
||||
- Detail modal showing:
|
||||
- Specimen attributes
|
||||
- Related order info
|
||||
- Collection details
|
||||
- Container information
|
||||
|
||||
### Decision: User Page - Basic CRUD Only
|
||||
**Rationale**: User management is admin function, not daily use. Complex features (permissions matrix, audit logs) can be added later.
|
||||
|
||||
Features:
|
||||
- List users with search
|
||||
- Create/edit user form
|
||||
- Soft delete (disable) users
|
||||
- Prevent self-deletion
|
||||
|
||||
Not included:
|
||||
- Permission matrix UI
|
||||
- User activity audit
|
||||
- Password reset flow (use existing)
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
**[Risk] API endpoints may not exist for specimens/users**
|
||||
→ **Status**: CONFIRMED - Specimen delete missing, User API not implemented
|
||||
→ **Mitigation**: Coordinate with backend team to add endpoints before frontend delete features
|
||||
|
||||
**[Risk] TestMap delete limitation blocks feature**
|
||||
→ **Mitigation**: Document limitation clearly, add informative error message, suggest backend improvement
|
||||
|
||||
**[Risk] Barcode printing may not work in all browsers**
|
||||
→ **Mitigation**: Use standard window.print(), test in target environments, provide fallback
|
||||
|
||||
**[Risk] User delete without RBAC checks**
|
||||
→ **Mitigation**: Add client-side check to prevent self-deletion, rely on backend for authorization
|
||||
|
||||
## Migration Plan
|
||||
|
||||
**Deployment**:
|
||||
1. Deploy frontend changes
|
||||
2. Verify new pages load correctly
|
||||
3. Test patient delete on staging data
|
||||
4. Verify sidebar navigation works
|
||||
|
||||
**Rollback**:
|
||||
- Revert to previous commit
|
||||
- No database changes required (frontend only)
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **What fields are available for specimens?**
|
||||
Need to review SpecimenModel for data structure
|
||||
|
||||
2. **Are there existing barcode requirements?**
|
||||
What format? Code128? QR codes? What data to encode?
|
||||
|
||||
3. **Should users have soft delete or hard delete?**
|
||||
Check if backend supports soft delete (status field)
|
||||
|
||||
## Backend Coordination Checklist
|
||||
|
||||
- [ ] Backend: Add Specimen delete endpoint
|
||||
- [ ] Backend: Create UserController
|
||||
- [ ] Backend: Create UserModel
|
||||
- [ ] Backend: Add user routes to Routes.php
|
||||
- [ ] Frontend: Implement specimen delete once API ready
|
||||
- [ ] Frontend: Implement users page once API ready
|
||||
@ -0,0 +1,63 @@
|
||||
## Why
|
||||
|
||||
The CLQMS frontend has several incomplete features marked with TODO comments that block core workflows. Additionally, the sidebar links to Specimens and Users pages that don't exist yet. Completing these TODOs and adding the missing pages will provide a fully functional laboratory management system with complete patient-to-results tracking and user administration capabilities.
|
||||
|
||||
## What Changes
|
||||
|
||||
### Backend TODOs (F)
|
||||
- **Patient Delete**: Wire up existing `deletePatient()` API (currently commented out)
|
||||
- **Order Detail View**: Create modal component to display order details, test list, and status history
|
||||
- **Barcode Printing**: Add print stubs and structure for patient wristbands and specimen labels
|
||||
- **TestMap Delete**: Document API limitation and add error handling for missing TestMapID
|
||||
|
||||
### New Pages
|
||||
- **Specimens Page** (A): Full CRUD page for specimen tracking with search, list, and detail views
|
||||
- **Users Page** (B): User management interface with CRUD operations
|
||||
|
||||
### Backend Requirements (Coordinate with clqms01-be)
|
||||
- **Specimen Delete API**: Add `DELETE /api/specimen/(:num)` endpoint to backend
|
||||
- **User Management API**: Create complete CRUD API for user management:
|
||||
- `GET /api/users` - List users
|
||||
- `GET /api/users/(:num)` - Get user details
|
||||
- `POST /api/users` - Create user
|
||||
- `PATCH /api/users` - Update user
|
||||
- `DELETE /api/users/(:num)` - Delete user
|
||||
|
||||
### Minor Improvements
|
||||
- Update sidebar navigation to point to new pages
|
||||
- Add proper error handling for incomplete features
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `specimen-management`: Specimen tracking and management interface
|
||||
- `user-management`: User administration and role management
|
||||
- `barcode-printing`: Barcode generation and printing for patients and specimens
|
||||
- `order-detail-view`: Detailed order inspection with test breakdown
|
||||
|
||||
### Modified Capabilities
|
||||
- `patient-management`: Add delete functionality to complete CRUD operations
|
||||
|
||||
## Impact
|
||||
|
||||
- **Frontend**: 3 new route pages, 2 new modal components, API integrations
|
||||
- **Backend**: New endpoints for specimen delete and user CRUD operations
|
||||
- **User Experience**: Completes core lab workflow from patient registration to results
|
||||
- **Breaking Changes**: None - additive changes only
|
||||
|
||||
## Dependencies
|
||||
|
||||
**Backend Changes Required (clqms01-be repo):**
|
||||
|
||||
| Feature | Backend Work | Status |
|
||||
|---------|--------------|--------|
|
||||
| Patient Delete | ✅ Already exists | Ready |
|
||||
| Specimens | ⚠️ Add DELETE endpoint | **BLOCKS specimen delete** |
|
||||
| Users | ❌ Create full CRUD API | **BLOCKS users page** |
|
||||
| Order Detail | ✅ Already exists | Ready |
|
||||
|
||||
**Coordination Strategy:**
|
||||
1. Backend team creates specimen delete endpoint
|
||||
2. Backend team creates user management API
|
||||
3. Frontend implements features as APIs become available
|
||||
4. Parallel development possible for non-dependent features
|
||||
@ -0,0 +1,30 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Barcodes can be printed for patients
|
||||
The system SHALL provide barcode printing functionality for patient identification.
|
||||
|
||||
#### Scenario: Print patient wristband
|
||||
- **WHEN** user clicks print barcode on a patient
|
||||
- **THEN** system generates barcode with patient ID
|
||||
- **AND** opens print dialog with wristband layout
|
||||
- **AND** includes patient name and ID on wristband
|
||||
|
||||
#### Scenario: Print specimen label
|
||||
- **WHEN** user clicks print barcode on an order
|
||||
- **THEN** system generates barcode with order/specimen ID
|
||||
- **AND** opens print dialog with label layout
|
||||
- **AND** includes patient info, test codes, and date
|
||||
|
||||
### Requirement: Barcode printing structure is prepared
|
||||
The system SHALL have barcode printing infrastructure ready for full implementation.
|
||||
|
||||
#### Scenario: Barcode generation stub exists
|
||||
- **WHEN** print barcode is triggered
|
||||
- **THEN** system calls barcode generation function
|
||||
- **AND** function returns placeholder for now
|
||||
- **AND** displays coming soon message
|
||||
|
||||
#### Scenario: Print dialog integration
|
||||
- **WHEN** barcode data is ready
|
||||
- **THEN** system opens browser print dialog
|
||||
- **AND** provides formatted layout for printing
|
||||
@ -0,0 +1,28 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Order details can be viewed
|
||||
The system SHALL provide a modal to view detailed order information.
|
||||
|
||||
#### Scenario: View order details
|
||||
- **WHEN** user clicks on an order in the list
|
||||
- **THEN** system opens order detail modal
|
||||
- **AND** displays order header information (number, date, status)
|
||||
- **AND** shows patient information
|
||||
- **AND** lists all tests in the order
|
||||
|
||||
#### Scenario: View order test details
|
||||
- **WHEN** viewing order details
|
||||
- **THEN** system displays each test with code, name, and status
|
||||
- **AND** shows specimen requirements
|
||||
- **AND** indicates if results are entered
|
||||
|
||||
#### Scenario: View order status history
|
||||
- **WHEN** viewing order details
|
||||
- **THEN** system shows status change history
|
||||
- **AND** includes timestamps for each status change
|
||||
- **AND** shows who performed each status change
|
||||
|
||||
#### Scenario: Close order detail modal
|
||||
- **WHEN** user clicks close or outside modal
|
||||
- **THEN** system closes order detail modal
|
||||
- **AND** returns to order list
|
||||
@ -0,0 +1,16 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Patient can be deleted
|
||||
The system SHALL allow authorized users to delete patients from the system.
|
||||
|
||||
#### Scenario: Successful patient deletion
|
||||
- **WHEN** user clicks delete button on a patient
|
||||
- **AND** user confirms deletion in the confirmation dialog
|
||||
- **THEN** system calls deletePatient API with InternalPID
|
||||
- **AND** patient is removed from the list
|
||||
- **AND** success message is displayed
|
||||
|
||||
#### Scenario: Deletion failure
|
||||
- **WHEN** deletePatient API returns an error
|
||||
- **THEN** error message is displayed
|
||||
- **AND** patient remains in the list
|
||||
@ -0,0 +1,34 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Specimens can be viewed and managed
|
||||
The system SHALL provide a page to view, search, and manage laboratory specimens.
|
||||
|
||||
#### Scenario: View specimen list
|
||||
- **WHEN** user navigates to /specimens
|
||||
- **THEN** system displays a list of specimens with filtering options
|
||||
- **AND** list includes specimen ID, type, status, and related order
|
||||
|
||||
#### Scenario: Search specimens
|
||||
- **WHEN** user enters search criteria
|
||||
- **THEN** system filters specimen list matching criteria
|
||||
- **AND** supports filtering by specimen type, status, date range
|
||||
|
||||
#### Scenario: View specimen details
|
||||
- **WHEN** user clicks on a specimen
|
||||
- **THEN** system displays specimen detail modal
|
||||
- **AND** shows all specimen attributes and related information
|
||||
|
||||
#### Scenario: Create new specimen
|
||||
- **WHEN** user clicks "New Specimen" button
|
||||
- **THEN** system opens specimen creation form
|
||||
- **AND** saves specimen on form submission
|
||||
|
||||
#### Scenario: Edit specimen
|
||||
- **WHEN** user clicks edit on a specimen
|
||||
- **THEN** system opens specimen edit form
|
||||
- **AND** updates specimen on form submission
|
||||
|
||||
#### Scenario: Delete specimen
|
||||
- **WHEN** user clicks delete on a specimen
|
||||
- **AND** user confirms deletion
|
||||
- **THEN** system removes specimen from database
|
||||
@ -0,0 +1,31 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Users can be managed
|
||||
The system SHALL provide an interface for administrators to manage system users.
|
||||
|
||||
#### Scenario: View user list
|
||||
- **WHEN** administrator navigates to /master-data/users
|
||||
- **THEN** system displays a list of users
|
||||
- **AND** list includes username, role, status, and department
|
||||
|
||||
#### Scenario: Create new user
|
||||
- **WHEN** administrator clicks "New User" button
|
||||
- **THEN** system opens user creation form
|
||||
- **AND** form includes fields for username, email, role, password
|
||||
- **AND** system saves user on form submission
|
||||
|
||||
#### Scenario: Edit user
|
||||
- **WHEN** administrator clicks edit on a user
|
||||
- **THEN** system opens user edit form
|
||||
- **AND** allows modification of user attributes
|
||||
- **AND** updates user on form submission
|
||||
|
||||
#### Scenario: Delete user
|
||||
- **WHEN** administrator clicks delete on a user
|
||||
- **AND** administrator confirms deletion
|
||||
- **THEN** system removes user from database
|
||||
- **AND** prevents deletion of own account
|
||||
|
||||
#### Scenario: Search users
|
||||
- **WHEN** administrator enters search term
|
||||
- **THEN** system filters user list by username, email, or role
|
||||
@ -0,0 +1,92 @@
|
||||
## 0. Backend Coordination (clqms01-be repo)
|
||||
|
||||
**These backend changes must be completed before dependent frontend tasks:**
|
||||
|
||||
- [ ] 0.1 Add Specimen delete endpoint to Routes.php
|
||||
- [ ] 0.2 Implement SpecimenController::delete() method
|
||||
- [ ] 0.3 Create UserController with CRUD methods
|
||||
- [ ] 0.4 Create UserModel for database operations
|
||||
- [ ] 0.5 Add user routes to Routes.php
|
||||
- [ ] 0.6 Test backend endpoints with Postman/curl
|
||||
|
||||
## 1. Backend TODOs - Patient Delete
|
||||
|
||||
- [x] 1.1 Import deletePatient function in patients/+page.svelte
|
||||
- [x] 1.2 Uncomment delete API call in handleDelete function
|
||||
- [x] 1.3 Add error handling for delete operation
|
||||
- [ ] 1.4 Test patient delete functionality
|
||||
|
||||
## 2. Backend TODOs - Order Detail View
|
||||
|
||||
- [x] 2.1 Create OrderDetailModal.svelte component
|
||||
- [x] 2.2 Add order header display (number, date, status, patient)
|
||||
- [x] 2.3 Add test list display with status indicators
|
||||
- [x] 2.4 Add status history timeline
|
||||
- [x] 2.5 Wire up modal to order list click handler
|
||||
- [ ] 2.6 Test order detail modal
|
||||
|
||||
## 3. Backend TODOs - Barcode Printing Structure
|
||||
|
||||
- [x] 3.1 Create barcode.js utility file with print functions
|
||||
- [x] 3.2 Add printPatientWristband stub function
|
||||
- [x] 3.3 Add printSpecimenLabel stub function
|
||||
- [x] 3.4 Wire up barcode printing to patient order list
|
||||
- [x] 3.5 Wire up barcode printing to orders page
|
||||
- [ ] 3.6 Test print dialog opens correctly
|
||||
|
||||
## 4. Backend TODOs - TestMap Delete Documentation
|
||||
|
||||
- [x] 4.1 Add informative error message for TestMap delete limitation
|
||||
- [x] 4.2 Document API limitation in code comments
|
||||
- [x] 4.3 Add console warning with explanation
|
||||
|
||||
## 5. Specimens Page
|
||||
|
||||
- [x] 5.1 Create specimens API client file (src/lib/api/specimens.js)
|
||||
- [x] 5.2 Create specimens/+page.svelte route
|
||||
- [x] 5.3 Add specimen search/filter bar
|
||||
- [x] 5.4 Add specimen data table with columns
|
||||
- [x] 5.5 Create SpecimenFormModal component
|
||||
- [x] 5.6 Implement specimen creation
|
||||
- [x] 5.7 Implement specimen editing
|
||||
- [ ] 5.8 Implement specimen deletion **[BLOCKED: Requires task 0.1-0.2]**
|
||||
- [x] 5.9 Create SpecimenDetailModal component
|
||||
- [x] 5.10 Wire up detail view
|
||||
- [ ] 5.11 Test full specimen CRUD workflow
|
||||
|
||||
## 6. Users Page
|
||||
|
||||
- [x] 6.1 Create users API client file (src/lib/api/users.js)
|
||||
- [x] 6.2 Create master-data/users/+page.svelte route **[API NOT READY]**
|
||||
- [x] 6.3 Add user search functionality **[API NOT READY]**
|
||||
- [x] 6.4 Add user data table **[API NOT READY]**
|
||||
- [x] 6.5 Create UserFormModal component **[API NOT READY]**
|
||||
- [x] 6.6 Implement user creation **[API NOT READY]**
|
||||
- [x] 6.7 Implement user editing **[API NOT READY]**
|
||||
- [x] 6.8 Implement user deletion with self-delete prevention **[API NOT READY]**
|
||||
- [ ] 6.9 Test user management workflow **[BLOCKED: Pending backend API]**
|
||||
|
||||
## 7. Navigation Updates
|
||||
|
||||
- [x] 7.1 Verify specimens link in sidebar works
|
||||
- [x] 7.2 Verify users link in sidebar works
|
||||
- [x] 7.3 Update any broken navigation paths
|
||||
- [ ] 7.4 Test all navigation flows **[PARTIALLY BLOCKED]**
|
||||
|
||||
## 8. Integration & Testing
|
||||
|
||||
- [ ] 8.1 Run development server
|
||||
- [ ] 8.2 Test patient delete end-to-end
|
||||
- [ ] 8.3 Test order detail modal
|
||||
- [ ] 8.4 Test barcode print dialogs
|
||||
- [ ] 8.5 Test specimens page CRUD **[PARTIALLY BLOCKED: Delete pending backend]**
|
||||
- [ ] 8.6 Test users page CRUD **[BLOCKED: Pending backend]**
|
||||
- [ ] 8.7 Verify no console errors
|
||||
- [ ] 8.8 Code review and cleanup
|
||||
|
||||
## 9. Deployment
|
||||
|
||||
- [ ] 9.1 Coordinate backend deployment
|
||||
- [ ] 9.2 Deploy frontend changes
|
||||
- [ ] 9.3 Verify all features in production
|
||||
- [ ] 9.4 Update API documentation
|
||||
20
openspec/config.yaml
Normal file
20
openspec/config.yaml
Normal file
@ -0,0 +1,20 @@
|
||||
schema: spec-driven
|
||||
|
||||
# Project context (optional)
|
||||
# This is shown to AI when creating artifacts.
|
||||
# Add your tech stack, conventions, style guides, domain knowledge, etc.
|
||||
# Example:
|
||||
# context: |
|
||||
# Tech stack: TypeScript, React, Node.js
|
||||
# We use conventional commits
|
||||
# Domain: e-commerce platform
|
||||
|
||||
# Per-artifact rules (optional)
|
||||
# Add custom rules for specific artifacts.
|
||||
# Example:
|
||||
# rules:
|
||||
# proposal:
|
||||
# - Keep proposals under 500 words
|
||||
# - Always include a "Non-goals" section
|
||||
# tasks:
|
||||
# - Break tasks into chunks of max 2 hours
|
||||
@ -45,9 +45,9 @@ export async function fetchDepartment(id) {
|
||||
|
||||
export async function createDepartment(data) {
|
||||
const payload = {
|
||||
DepartmentCode: data.DepartmentCode,
|
||||
DepartmentName: data.DepartmentName,
|
||||
DisciplineID: data.DisciplineID,
|
||||
DeptCode: data.DeptCode,
|
||||
DeptName: data.DeptName,
|
||||
SiteID: data.SiteID,
|
||||
};
|
||||
return post('/api/organization/department', payload);
|
||||
}
|
||||
@ -55,9 +55,9 @@ export async function createDepartment(data) {
|
||||
export async function updateDepartment(data) {
|
||||
const payload = {
|
||||
id: data.DepartmentID,
|
||||
DepartmentCode: data.DepartmentCode,
|
||||
DepartmentName: data.DepartmentName,
|
||||
DisciplineID: data.DisciplineID,
|
||||
DeptCode: data.DeptCode,
|
||||
DeptName: data.DeptName,
|
||||
SiteID: data.SiteID,
|
||||
};
|
||||
return patch('/api/organization/department', payload);
|
||||
}
|
||||
@ -80,6 +80,7 @@ export async function createSite(data) {
|
||||
const payload = {
|
||||
SiteCode: data.SiteCode,
|
||||
SiteName: data.SiteName,
|
||||
AccountID: data.AccountID,
|
||||
};
|
||||
return post('/api/organization/site', payload);
|
||||
}
|
||||
@ -89,6 +90,7 @@ export async function updateSite(data) {
|
||||
id: data.SiteID,
|
||||
SiteCode: data.SiteCode,
|
||||
SiteName: data.SiteName,
|
||||
AccountID: data.AccountID,
|
||||
};
|
||||
return patch('/api/organization/site', payload);
|
||||
}
|
||||
@ -205,3 +207,63 @@ export async function updateCodingSystem(data) {
|
||||
export async function deleteCodingSystem(id) {
|
||||
return del('/api/organization/codingsys', { id });
|
||||
}
|
||||
|
||||
|
||||
// Accounts
|
||||
export async function fetchAccounts(params = {}) {
|
||||
const query = new URLSearchParams(params).toString();
|
||||
return get(query ? `/api/organization/account?${query}` : '/api/organization/account');
|
||||
}
|
||||
|
||||
export async function fetchAccount(id) {
|
||||
return get(`/api/organization/account/${id}`);
|
||||
}
|
||||
|
||||
export async function createAccount(data) {
|
||||
const payload = {
|
||||
AccountName: data.AccountName,
|
||||
Initial: data.Initial,
|
||||
Parent: data.Parent,
|
||||
};
|
||||
return post('/api/organization/account', payload);
|
||||
}
|
||||
|
||||
export async function updateAccount(data) {
|
||||
const payload = {
|
||||
id: data.AccountID,
|
||||
AccountName: data.AccountName,
|
||||
Initial: data.Initial,
|
||||
Parent: data.Parent,
|
||||
};
|
||||
return patch('/api/organization/account', payload);
|
||||
}
|
||||
|
||||
export async function deleteAccount(id) {
|
||||
return del('/api/organization/account', { id });
|
||||
}
|
||||
|
||||
// Workstations
|
||||
export async function createWorkstation(data) {
|
||||
const payload = {
|
||||
WorkstationCode: data.WorkstationCode,
|
||||
WorkstationName: data.WorkstationName,
|
||||
SiteID: data.SiteID,
|
||||
DepartmentID: data.DepartmentID,
|
||||
};
|
||||
return post('/api/organization/workstation', payload);
|
||||
}
|
||||
|
||||
export async function updateWorkstation(data) {
|
||||
const payload = {
|
||||
id: data.WorkstationID,
|
||||
WorkstationCode: data.WorkstationCode,
|
||||
WorkstationName: data.WorkstationName,
|
||||
SiteID: data.SiteID,
|
||||
DepartmentID: data.DepartmentID,
|
||||
};
|
||||
return patch('/api/organization/workstation', payload);
|
||||
}
|
||||
|
||||
export async function deleteWorkstation(id) {
|
||||
return del('/api/organization/workstation', { id });
|
||||
}
|
||||
|
||||
@ -1,14 +1,92 @@
|
||||
import { get } from './client.js';
|
||||
import { get, post, patch, del } from './client.js';
|
||||
|
||||
/**
|
||||
* Fetch specimens list with optional filters and pagination
|
||||
* @param {Object} params - Query parameters
|
||||
* @param {number} [params.page=1] - Page number
|
||||
* @param {number} [params.perPage=20] - Items per page
|
||||
* @param {string} [params.SpecimenID] - Filter by specimen ID
|
||||
* @param {string} [params.SpecimenType] - Filter by specimen type
|
||||
* @param {string} [params.Status] - Filter by status
|
||||
* @param {string} [params.OrderID] - Filter by order ID
|
||||
* @returns {Promise<Object>} API response with specimen data and pagination
|
||||
*/
|
||||
export async function fetchSpecimens(params = {}) {
|
||||
const query = new URLSearchParams(params).toString();
|
||||
return get(query ? `/api/specimen?${query}` : '/api/specimen');
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single specimen by ID
|
||||
* @param {number} id - Specimen ID
|
||||
* @returns {Promise<Object>} API response with specimen details
|
||||
*/
|
||||
export async function fetchSpecimen(id) {
|
||||
return get(`/api/specimen/${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new specimen
|
||||
* @param {Object} data - Specimen data
|
||||
* @param {string} data.SpecimenID - Specimen identifier (required)
|
||||
* @param {string} data.SpecimenType - Type of specimen (required)
|
||||
* @param {string} [data.OrderID] - Associated order ID
|
||||
* @param {string} [data.Status] - Specimen status
|
||||
* @param {string} [data.CollectionDate] - Collection date/time
|
||||
* @param {string} [data.CollectedBy] - Collector identifier
|
||||
* @param {string} [data.ContainerType] - Container type
|
||||
* @param {string} [data.Volume] - Volume collected
|
||||
* @param {string} [data.Units] - Volume units
|
||||
* @param {string} [data.Comment] - Comments
|
||||
* @returns {Promise<Object>} API response
|
||||
*/
|
||||
export async function createSpecimen(data) {
|
||||
return post('/api/specimen', data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing specimen
|
||||
* @param {number} id - Specimen ID
|
||||
* @param {Object} data - Specimen data to update
|
||||
* @returns {Promise<Object>} API response
|
||||
*/
|
||||
export async function updateSpecimen(id, data) {
|
||||
return patch(`/api/specimen/${id}`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a specimen
|
||||
* @param {number} id - Specimen ID
|
||||
* @returns {Promise<Object>} API response
|
||||
*/
|
||||
export async function deleteSpecimen(id) {
|
||||
return del(`/api/specimen/${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch specimen types for dropdown
|
||||
* @param {Object} params - Query parameters
|
||||
* @returns {Promise<Object>} API response with specimen types
|
||||
*/
|
||||
export async function fetchSpecimenTypes(params = {}) {
|
||||
const query = new URLSearchParams(params).toString();
|
||||
return get(query ? `/api/specimen/type?${query}` : '/api/specimen/type');
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single specimen type by ID
|
||||
* @param {number} id - Specimen type ID
|
||||
* @returns {Promise<Object>} API response with specimen type details
|
||||
*/
|
||||
export async function fetchSpecimenType(id) {
|
||||
return get(`/api/specimen/type/${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch specimen collections for dropdown
|
||||
* @param {Object} params - Query parameters
|
||||
* @returns {Promise<Object>} API response with collection methods
|
||||
*/
|
||||
export async function fetchSpecimenCollections(params = {}) {
|
||||
const query = new URLSearchParams(params).toString();
|
||||
return get(query ? `/api/specimen/collection?${query}` : '/api/specimen/collection');
|
||||
|
||||
61
src/lib/api/users.js
Normal file
61
src/lib/api/users.js
Normal file
@ -0,0 +1,61 @@
|
||||
import { get, post, patch, del } from './client.js';
|
||||
|
||||
/**
|
||||
* Fetch users list with optional filters and pagination
|
||||
* @param {Object} params - Query parameters
|
||||
* @param {number} [params.page=1] - Page number
|
||||
* @param {number} [params.perPage=20] - Items per page
|
||||
* @param {string} [params.search] - Search by username, email, or name
|
||||
* @param {string} [params.role] - Filter by role
|
||||
* @param {string} [params.status] - Filter by status
|
||||
* @returns {Promise<Object>} API response with user data and pagination
|
||||
*/
|
||||
export async function fetchUsers(params = {}) {
|
||||
const query = new URLSearchParams(params).toString();
|
||||
return get(query ? `/api/users?${query}` : '/api/users');
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a single user by ID
|
||||
* @param {number} id - User ID
|
||||
* @returns {Promise<Object>} API response with user details
|
||||
*/
|
||||
export async function fetchUser(id) {
|
||||
return get(`/api/users/${id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new user
|
||||
* @param {Object} data - User data
|
||||
* @param {string} data.Username - Username (required)
|
||||
* @param {string} data.Email - Email address (required)
|
||||
* @param {string} data.Password - Password (required)
|
||||
* @param {string} [data.NameFirst] - First name
|
||||
* @param {string} [data.NameLast] - Last name
|
||||
* @param {string} [data.Role] - User role
|
||||
* @param {string} [data.Department] - Department
|
||||
* @param {string} [data.Status] - User status (active/inactive)
|
||||
* @returns {Promise<Object>} API response
|
||||
*/
|
||||
export async function createUser(data) {
|
||||
return post('/api/users', data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing user
|
||||
* @param {number} id - User ID
|
||||
* @param {Object} data - User data to update
|
||||
* @returns {Promise<Object>} API response
|
||||
*/
|
||||
export async function updateUser(id, data) {
|
||||
return patch(`/api/users/${id}`, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a user
|
||||
* @param {number} id - User ID
|
||||
* @returns {Promise<Object>} API response
|
||||
*/
|
||||
export async function deleteUser(id) {
|
||||
return del(`/api/users/${id}`);
|
||||
}
|
||||
@ -27,7 +27,8 @@ import {
|
||||
User,
|
||||
Server,
|
||||
Network,
|
||||
FileCode
|
||||
FileCode,
|
||||
History
|
||||
} from 'lucide-svelte';
|
||||
import { auth } from '$lib/stores/auth.js';
|
||||
import { goto } from '$app/navigation';
|
||||
@ -202,14 +203,14 @@ function toggleLaboratory() {
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
{#if isOpen && laboratoryExpanded}
|
||||
{#if isOpen && laboratoryExpanded}
|
||||
<ul class="submenu">
|
||||
<li><a href="/patients" class="submenu-link"><Users size={16} /> Patients</a></li>
|
||||
<li><a href="/visits" class="submenu-link"><Activity size={16} /> Visits</a></li>
|
||||
<li><a href="/orders" class="submenu-link"><ClipboardList size={16} /> Orders</a></li>
|
||||
<li><a href="/specimens" class="submenu-link"><FlaskConical size={16} /> Specimens</a></li>
|
||||
<li><a href="/result-entry" class="submenu-link"><FileText size={16} /> Result Entry</a></li>
|
||||
<li><a href="/results" class="submenu-link"><CheckCircle2 size={16} /> Results</a></li>
|
||||
<li><a href="/patient-results" class="submenu-link"><History size={16} /> Patient History</a></li>
|
||||
</ul>
|
||||
{/if}
|
||||
</li>
|
||||
|
||||
199
src/lib/utils/barcode.js
Normal file
199
src/lib/utils/barcode.js
Normal file
@ -0,0 +1,199 @@
|
||||
/**
|
||||
* Barcode printing utility functions
|
||||
* Provides structure for patient wristbands and specimen labels
|
||||
* Note: Full barcode generation to be implemented with library integration
|
||||
*/
|
||||
|
||||
/**
|
||||
* Print patient wristband with barcode
|
||||
* @param {Object} patient - Patient data
|
||||
* @param {string} patient.PatientID - Patient identifier
|
||||
* @param {string} patient.NameFirst - First name
|
||||
* @param {string} patient.NameLast - Last name
|
||||
* @param {string} patient.Birthdate - Birthdate
|
||||
* @param {string} patient.Sex - Sex (1=Female, 2=Male)
|
||||
*/
|
||||
export function printPatientWristband(patient) {
|
||||
if (!patient?.PatientID) {
|
||||
console.warn('Cannot print wristband: Missing patient ID');
|
||||
return;
|
||||
}
|
||||
|
||||
const sexLabel = patient.Sex === '1' ? 'F' : patient.Sex === '2' ? 'M' : 'U';
|
||||
const birthdate = patient.Birthdate
|
||||
? new Date(patient.Birthdate).toLocaleDateString('en-US')
|
||||
: '-';
|
||||
|
||||
const patientName = [patient.NameFirst, patient.NameLast]
|
||||
.filter(Boolean)
|
||||
.join(' ') || 'Unknown';
|
||||
|
||||
const printContent = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Patient Wristband - ${patient.PatientID}</title>
|
||||
<style>
|
||||
@media print {
|
||||
body { margin: 0; padding: 10mm; font-family: Arial, sans-serif; }
|
||||
.wristband {
|
||||
width: 250mm;
|
||||
height: 25mm;
|
||||
border: 2px solid #000;
|
||||
padding: 3mm;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5mm;
|
||||
}
|
||||
.barcode-placeholder {
|
||||
width: 80mm;
|
||||
height: 18mm;
|
||||
background: #f0f0f0;
|
||||
border: 1px dashed #999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 10px;
|
||||
color: #666;
|
||||
}
|
||||
.patient-info { flex: 1; }
|
||||
.patient-id { font-size: 14px; font-weight: bold; }
|
||||
.patient-name { font-size: 12px; margin-top: 2mm; }
|
||||
.patient-details { font-size: 10px; color: #666; margin-top: 1mm; }
|
||||
}
|
||||
@media screen {
|
||||
body { background: #f5f5f5; padding: 20px; }
|
||||
.wristband { background: white; margin: 20px auto; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wristband">
|
||||
<div class="barcode-placeholder">
|
||||
[BARCODE]<br>${patient.PatientID}
|
||||
</div>
|
||||
<div class="patient-info">
|
||||
<div class="patient-id">${patient.PatientID}</div>
|
||||
<div class="patient-name">${patientName}</div>
|
||||
<div class="patient-details">DOB: ${birthdate} | Sex: ${sexLabel}</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
window.onload = function() { window.print(); };
|
||||
<\/script>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
const printWindow = window.open('', '_blank', 'width=800,height=400');
|
||||
if (printWindow) {
|
||||
printWindow.document.write(printContent);
|
||||
printWindow.document.close();
|
||||
} else {
|
||||
console.error('Failed to open print window - popup blocker may be enabled');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Print specimen label with barcode
|
||||
* @param {Object} order - Order data
|
||||
* @param {string} order.OrderID - Order identifier
|
||||
* @param {string} order.OrderNumber - Order number
|
||||
* @param {string} order.PatientID - Patient ID
|
||||
* @param {string} order.PatientName - Patient name
|
||||
* @param {Array} order.Tests - Array of tests
|
||||
* @param {string} order.OrderDate - Order date
|
||||
*/
|
||||
export function printSpecimenLabel(order) {
|
||||
if (!order?.OrderID) {
|
||||
console.warn('Cannot print label: Missing order ID');
|
||||
return;
|
||||
}
|
||||
|
||||
const orderDate = order.OrderDate
|
||||
? new Date(order.OrderDate).toLocaleDateString('en-US')
|
||||
: new Date().toLocaleDateString('en-US');
|
||||
|
||||
const testCodes = order.Tests?.map(t => t.TestSiteID || t.TestID).join(', ') || 'N/A';
|
||||
const patientName = order.PatientName || 'Unknown';
|
||||
const patientId = order.PatientID || order.InternalPID || 'N/A';
|
||||
|
||||
const printContent = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Specimen Label - ${order.OrderNumber || order.OrderID}</title>
|
||||
<style>
|
||||
@media print {
|
||||
body { margin: 0; padding: 5mm; font-family: Arial, sans-serif; }
|
||||
.label {
|
||||
width: 100mm;
|
||||
height: 60mm;
|
||||
border: 1px solid #000;
|
||||
padding: 3mm;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.barcode-placeholder {
|
||||
width: 94mm;
|
||||
height: 20mm;
|
||||
background: #f0f0f0;
|
||||
border: 1px dashed #999;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 10px;
|
||||
color: #666;
|
||||
margin-bottom: 2mm;
|
||||
}
|
||||
.order-info { font-size: 11px; margin-bottom: 1mm; }
|
||||
.patient-info { font-size: 10px; color: #333; margin-bottom: 1mm; }
|
||||
.test-codes {
|
||||
font-size: 9px;
|
||||
color: #666;
|
||||
border-top: 1px solid #ddd;
|
||||
padding-top: 1mm;
|
||||
margin-top: 2mm;
|
||||
}
|
||||
.date-info { font-size: 9px; color: #999; margin-top: 2mm; }
|
||||
}
|
||||
@media screen {
|
||||
body { background: #f5f5f5; padding: 20px; }
|
||||
.label { background: white; margin: 20px auto; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="label">
|
||||
<div class="barcode-placeholder">
|
||||
[BARCODE]<br>${order.OrderNumber || order.OrderID}
|
||||
</div>
|
||||
<div class="order-info"><strong>Order:</strong> ${order.OrderNumber || order.OrderID}</div>
|
||||
<div class="patient-info"><strong>Patient:</strong> ${patientName} (${patientId})</div>
|
||||
<div class="test-codes"><strong>Tests:</strong> ${testCodes}</div>
|
||||
<div class="date-info">Collected: ${orderDate}</div>
|
||||
</div>
|
||||
<script>
|
||||
window.onload = function() { window.print(); };
|
||||
<\/script>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
const printWindow = window.open('', '_blank', 'width=600,height=400');
|
||||
if (printWindow) {
|
||||
printWindow.document.write(printContent);
|
||||
printWindow.document.close();
|
||||
} else {
|
||||
console.error('Failed to open print window - popup blocker may be enabled');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show coming soon message for barcode features
|
||||
* @param {string} feature - Feature name
|
||||
*/
|
||||
export function showBarcodeComingSoon(feature = 'Barcode printing') {
|
||||
// This is a placeholder for when full barcode library is integrated
|
||||
console.log(`${feature} - Full barcode generation coming soon`);
|
||||
}
|
||||
@ -1,5 +1,11 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import {
|
||||
fetchAccounts,
|
||||
createAccount,
|
||||
updateAccount,
|
||||
deleteAccount,
|
||||
} from '$lib/api/organization.js';
|
||||
import { success as toastSuccess, error as toastError } from '$lib/utils/toast.js';
|
||||
import DataTable from '$lib/components/DataTable.svelte';
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
@ -12,8 +18,9 @@
|
||||
let saving = $state(false);
|
||||
let formData = $state({
|
||||
AccountID: null,
|
||||
AccountCode: '',
|
||||
AccountName: '',
|
||||
Initial: '',
|
||||
Parent: null,
|
||||
});
|
||||
let deleteConfirmOpen = $state(false);
|
||||
let deleteItem = $state(null);
|
||||
@ -21,8 +28,9 @@
|
||||
let searchQuery = $state('');
|
||||
|
||||
const columns = [
|
||||
{ key: 'AccountCode', label: 'Code', class: 'font-medium' },
|
||||
{ key: 'Initial', label: 'Initial', class: 'font-medium w-24' },
|
||||
{ key: 'AccountName', label: 'Name' },
|
||||
{ key: 'ParentName', label: 'Parent', class: 'w-48' },
|
||||
{ key: 'actions', label: 'Actions', class: 'w-32 text-center' },
|
||||
];
|
||||
|
||||
@ -33,10 +41,8 @@
|
||||
async function loadItems() {
|
||||
loading = true;
|
||||
try {
|
||||
// TODO: Replace with actual API call
|
||||
// const response = await fetchAccounts();
|
||||
// items = Array.isArray(response.data) ? response.data : [];
|
||||
items = []; // Placeholder until API is integrated
|
||||
const response = await fetchAccounts();
|
||||
items = Array.isArray(response.data) ? response.data : [];
|
||||
} catch (err) {
|
||||
toastError(err.message || 'Failed to load accounts');
|
||||
items = [];
|
||||
@ -45,12 +51,19 @@
|
||||
}
|
||||
}
|
||||
|
||||
const itemsWithParentName = $derived(
|
||||
items.map((item) => ({
|
||||
...item,
|
||||
ParentName: item.ParentName || (item.Parent ? 'Parent ' + item.Parent : '-'),
|
||||
}))
|
||||
);
|
||||
|
||||
const filteredItems = $derived(
|
||||
items.filter((item) => {
|
||||
itemsWithParentName.filter((item) => {
|
||||
if (!searchQuery.trim()) return true;
|
||||
const query = searchQuery.toLowerCase();
|
||||
return (
|
||||
(item.AccountCode && item.AccountCode.toLowerCase().includes(query)) ||
|
||||
(item.Initial && item.Initial.toLowerCase().includes(query)) ||
|
||||
(item.AccountName && item.AccountName.toLowerCase().includes(query))
|
||||
);
|
||||
})
|
||||
@ -58,25 +71,22 @@
|
||||
|
||||
function openCreateModal() {
|
||||
modalMode = 'create';
|
||||
formData = { AccountID: null, AccountCode: '', AccountName: '' };
|
||||
formData = { AccountID: null, AccountName: '', Initial: '', Parent: null };
|
||||
modalOpen = true;
|
||||
}
|
||||
|
||||
function openEditModal(row) {
|
||||
modalMode = 'edit';
|
||||
formData = {
|
||||
AccountID: row.AccountID,
|
||||
AccountCode: row.AccountCode,
|
||||
AccountID: row.id || row.AccountID,
|
||||
AccountName: row.AccountName,
|
||||
Initial: row.Initial || '',
|
||||
Parent: row.Parent || null,
|
||||
};
|
||||
modalOpen = true;
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!formData.AccountCode.trim()) {
|
||||
toastError('Account code is required');
|
||||
return;
|
||||
}
|
||||
if (!formData.AccountName.trim()) {
|
||||
toastError('Account name is required');
|
||||
return;
|
||||
@ -84,15 +94,13 @@
|
||||
|
||||
saving = true;
|
||||
try {
|
||||
// TODO: Replace with actual API call
|
||||
// if (modalMode === 'create') {
|
||||
// await createAccount(formData);
|
||||
// toastSuccess('Account created successfully');
|
||||
// } else {
|
||||
// await updateAccount(formData);
|
||||
// toastSuccess('Account updated successfully');
|
||||
// }
|
||||
toastSuccess('Account saved successfully (API integration pending)');
|
||||
if (modalMode === 'create') {
|
||||
await createAccount(formData);
|
||||
toastSuccess('Account created successfully');
|
||||
} else {
|
||||
await updateAccount(formData);
|
||||
toastSuccess('Account updated successfully');
|
||||
}
|
||||
modalOpen = false;
|
||||
await loadItems();
|
||||
} catch (err) {
|
||||
@ -110,9 +118,9 @@
|
||||
async function handleDelete() {
|
||||
deleting = true;
|
||||
try {
|
||||
// TODO: Replace with actual API call
|
||||
// await deleteAccount(deleteItem.AccountID);
|
||||
toastSuccess('Account deleted successfully (API integration pending)');
|
||||
const accountId = deleteItem.id || deleteItem.AccountID;
|
||||
await deleteAccount(accountId);
|
||||
toastSuccess('Account deleted successfully');
|
||||
deleteConfirmOpen = false;
|
||||
deleteItem = null;
|
||||
await loadItems();
|
||||
@ -210,21 +218,6 @@
|
||||
<Modal bind:open={modalOpen} title={modalMode === 'create' ? 'Add Account' : 'Edit Account'} size="md">
|
||||
<form class="space-y-4" onsubmit={(e) => { e.preventDefault(); handleSave(); }}>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="form-control">
|
||||
<label class="label" for="accountCode">
|
||||
<span class="label-text text-sm font-medium">Account Code</span>
|
||||
<span class="label-text-alt text-xs text-error">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="accountCode"
|
||||
type="text"
|
||||
class="input input-sm input-bordered w-full"
|
||||
bind:value={formData.AccountCode}
|
||||
placeholder="e.g., ACC001"
|
||||
required
|
||||
/>
|
||||
<span class="label-text-alt text-xs text-gray-500">Unique identifier for this account</span>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label" for="accountName">
|
||||
<span class="label-text text-sm font-medium">Account Name</span>
|
||||
@ -240,6 +233,35 @@
|
||||
/>
|
||||
<span class="label-text-alt text-xs text-gray-500">Display name for this account</span>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label" for="initial">
|
||||
<span class="label-text text-sm font-medium">Initial</span>
|
||||
</label>
|
||||
<input
|
||||
id="initial"
|
||||
type="text"
|
||||
class="input input-sm input-bordered w-full"
|
||||
bind:value={formData.Initial}
|
||||
placeholder="e.g., QAC"
|
||||
/>
|
||||
<span class="label-text-alt text-xs text-gray-500">Account initial/code</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label" for="parentAccount">
|
||||
<span class="label-text text-sm font-medium">Parent Account</span>
|
||||
</label>
|
||||
<select
|
||||
id="parentAccount"
|
||||
class="select select-sm select-bordered w-full"
|
||||
bind:value={formData.Parent}
|
||||
>
|
||||
<option value={null}>None (Top-level account)</option>
|
||||
{#each items.filter((a) => (a.id || a.AccountID) !== formData.AccountID) as account}
|
||||
<option value={account.id || account.AccountID}>{account.AccountName}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<span class="label-text-alt text-xs text-gray-500">Optional parent for hierarchical structure</span>
|
||||
</div>
|
||||
</form>
|
||||
{#snippet footer()}
|
||||
@ -258,10 +280,10 @@
|
||||
<p class="text-base-content/80">
|
||||
Are you sure you want to delete the following account?
|
||||
</p>
|
||||
<div class="bg-base-200 rounded-lg p-3 mt-3">
|
||||
<p class="font-semibold text-base-content">{deleteItem?.AccountName}</p>
|
||||
<p class="text-sm text-base-content/60">Code: {deleteItem?.AccountCode}</p>
|
||||
</div>
|
||||
<div class="bg-base-200 rounded-lg p-3 mt-3">
|
||||
<p class="font-semibold text-base-content">{deleteItem?.AccountName}</p>
|
||||
<p class="text-sm text-base-content/60">Initial: {deleteItem?.Initial || '-'}</p>
|
||||
</div>
|
||||
<p class="text-sm text-error mt-4">This action cannot be undone.</p>
|
||||
</div>
|
||||
{#snippet footer()}
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
createDepartment,
|
||||
updateDepartment,
|
||||
deleteDepartment,
|
||||
fetchDisciplines,
|
||||
fetchSites,
|
||||
} from '$lib/api/organization.js';
|
||||
import { success as toastSuccess, error as toastError } from '$lib/utils/toast.js';
|
||||
import DataTable from '$lib/components/DataTable.svelte';
|
||||
@ -14,15 +14,15 @@
|
||||
|
||||
let loading = $state(false);
|
||||
let items = $state([]);
|
||||
let disciplines = $state([]);
|
||||
let sites = $state([]);
|
||||
let modalOpen = $state(false);
|
||||
let modalMode = $state('create');
|
||||
let saving = $state(false);
|
||||
let formData = $state({
|
||||
DepartmentID: null,
|
||||
DepartmentCode: '',
|
||||
DepartmentName: '',
|
||||
DisciplineID: null,
|
||||
DeptCode: '',
|
||||
DeptName: '',
|
||||
SiteID: null,
|
||||
});
|
||||
let deleteConfirmOpen = $state(false);
|
||||
let deleteItem = $state(null);
|
||||
@ -30,23 +30,23 @@
|
||||
let searchQuery = $state('');
|
||||
|
||||
const columns = [
|
||||
{ key: 'DepartmentCode', label: 'Code', class: 'font-medium' },
|
||||
{ key: 'DepartmentName', label: 'Name' },
|
||||
{ key: 'DisciplineName', label: 'Discipline', class: 'w-48' },
|
||||
{ key: 'DeptCode', label: 'Code', class: 'font-medium' },
|
||||
{ key: 'DeptName', label: 'Name' },
|
||||
{ key: 'SiteName', label: 'Site', class: 'w-48' },
|
||||
{ key: 'actions', label: 'Actions', class: 'w-32 text-center' },
|
||||
];
|
||||
|
||||
onMount(async () => {
|
||||
await loadDisciplines();
|
||||
await loadSites();
|
||||
await loadItems();
|
||||
});
|
||||
|
||||
async function loadDisciplines() {
|
||||
async function loadSites() {
|
||||
try {
|
||||
const response = await fetchDisciplines();
|
||||
disciplines = Array.isArray(response.data) ? response.data : [];
|
||||
const response = await fetchSites();
|
||||
sites = Array.isArray(response.data) ? response.data : [];
|
||||
} catch (err) {
|
||||
disciplines = [];
|
||||
sites = [];
|
||||
}
|
||||
}
|
||||
|
||||
@ -63,48 +63,47 @@
|
||||
}
|
||||
}
|
||||
|
||||
const itemsWithDisciplineName = $derived(
|
||||
items.map((d) => ({
|
||||
...d,
|
||||
DisciplineName:
|
||||
disciplines.find((disc) => disc.DisciplineID === d.DisciplineID)?.DisciplineName || '-',
|
||||
const itemsWithSiteName = $derived(
|
||||
items.map((item) => ({
|
||||
...item,
|
||||
SiteName: sites.find((s) => s.id === item.SiteID || s.SiteID === item.SiteID)?.SiteName || '-',
|
||||
}))
|
||||
);
|
||||
|
||||
const filteredItems = $derived(
|
||||
itemsWithDisciplineName.filter((item) => {
|
||||
itemsWithSiteName.filter((item) => {
|
||||
if (!searchQuery.trim()) return true;
|
||||
const query = searchQuery.toLowerCase();
|
||||
return (
|
||||
(item.DepartmentCode && item.DepartmentCode.toLowerCase().includes(query)) ||
|
||||
(item.DepartmentName && item.DepartmentName.toLowerCase().includes(query))
|
||||
(item.DeptCode && item.DeptCode.toLowerCase().includes(query)) ||
|
||||
(item.DeptName && item.DeptName.toLowerCase().includes(query))
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
function openCreateModal() {
|
||||
modalMode = 'create';
|
||||
formData = { DepartmentID: null, DepartmentCode: '', DepartmentName: '', DisciplineID: null };
|
||||
formData = { DepartmentID: null, DeptCode: '', DeptName: '', SiteID: null };
|
||||
modalOpen = true;
|
||||
}
|
||||
|
||||
function openEditModal(row) {
|
||||
modalMode = 'edit';
|
||||
formData = {
|
||||
DepartmentID: row.DepartmentID,
|
||||
DepartmentCode: row.DepartmentCode,
|
||||
DepartmentName: row.DepartmentName,
|
||||
DisciplineID: row.DisciplineID,
|
||||
DepartmentID: row.id || row.DepartmentID,
|
||||
DeptCode: row.DeptCode || row.DepartmentCode || '',
|
||||
DeptName: row.DeptName || row.DepartmentName || '',
|
||||
SiteID: row.SiteID,
|
||||
};
|
||||
modalOpen = true;
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!formData.DepartmentCode.trim()) {
|
||||
if (!formData.DeptCode.trim()) {
|
||||
toastError('Department code is required');
|
||||
return;
|
||||
}
|
||||
if (!formData.DepartmentName.trim()) {
|
||||
if (!formData.DeptName.trim()) {
|
||||
toastError('Department name is required');
|
||||
return;
|
||||
}
|
||||
@ -135,7 +134,8 @@
|
||||
async function handleDelete() {
|
||||
deleting = true;
|
||||
try {
|
||||
await deleteDepartment(deleteItem.DepartmentID);
|
||||
const deptId = deleteItem.id || deleteItem.DepartmentID;
|
||||
await deleteDepartment(deptId);
|
||||
toastSuccess('Department deleted successfully');
|
||||
deleteConfirmOpen = false;
|
||||
deleteItem = null;
|
||||
@ -183,7 +183,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if !loading && filteredItems.length === 0}
|
||||
{#if !loading && filteredItems.length === 0}
|
||||
<div class="flex flex-col items-center justify-center py-16 px-4">
|
||||
<div class="w-16 h-16 rounded-full bg-base-200 flex items-center justify-center mb-4">
|
||||
<Users class="w-8 h-8 text-gray-400" />
|
||||
@ -215,10 +215,10 @@
|
||||
{#snippet cell({ column, row })}
|
||||
{#if column.key === 'actions'}
|
||||
<div class="flex justify-center gap-2">
|
||||
<button class="btn btn-sm btn-ghost" onclick={() => openEditModal(row)} aria-label="Edit {row.DepartmentName}">
|
||||
<button class="btn btn-sm btn-ghost" onclick={() => openEditModal(row)} aria-label="Edit {row.DeptName || row.DepartmentName}">
|
||||
<Edit2 class="w-4 h-4" />
|
||||
</button>
|
||||
<button class="btn btn-sm btn-ghost text-error" onclick={() => confirmDelete(row)} aria-label="Delete {row.DepartmentName}">
|
||||
<button class="btn btn-sm btn-ghost text-error" onclick={() => confirmDelete(row)} aria-label="Delete {row.DeptName || row.DepartmentName}">
|
||||
<Trash2 class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
@ -243,7 +243,7 @@
|
||||
id="departmentCode"
|
||||
type="text"
|
||||
class="input input-sm input-bordered w-full"
|
||||
bind:value={formData.DepartmentCode}
|
||||
bind:value={formData.DeptCode}
|
||||
placeholder="e.g., DEPT001"
|
||||
required
|
||||
/>
|
||||
@ -258,7 +258,7 @@
|
||||
id="departmentName"
|
||||
type="text"
|
||||
class="input input-sm input-bordered w-full"
|
||||
bind:value={formData.DepartmentName}
|
||||
bind:value={formData.DeptName}
|
||||
placeholder="e.g., Hematology"
|
||||
required
|
||||
/>
|
||||
@ -266,22 +266,22 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label" for="discipline">
|
||||
<span class="label-text text-sm font-medium">Discipline</span>
|
||||
<label class="label" for="site">
|
||||
<span class="label-text text-sm font-medium">Site</span>
|
||||
<span class="label-text-alt text-xs text-error">*</span>
|
||||
</label>
|
||||
<select
|
||||
id="discipline"
|
||||
id="site"
|
||||
class="select select-sm select-bordered w-full"
|
||||
bind:value={formData.DisciplineID}
|
||||
bind:value={formData.SiteID}
|
||||
required
|
||||
>
|
||||
<option value={null}>Select discipline...</option>
|
||||
{#each disciplines as discipline}
|
||||
<option value={discipline.DisciplineID}>{discipline.DisciplineName}</option>
|
||||
<option value={null}>Select site...</option>
|
||||
{#each sites as site}
|
||||
<option value={site.id || site.SiteID}>{site.SiteName}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<span class="label-text-alt text-xs text-gray-500">The discipline this department belongs to</span>
|
||||
<span class="label-text-alt text-xs text-gray-500">The site this department belongs to</span>
|
||||
</div>
|
||||
</form>
|
||||
{#snippet footer()}
|
||||
@ -301,8 +301,8 @@
|
||||
Are you sure you want to delete the following department?
|
||||
</p>
|
||||
<div class="bg-base-200 rounded-lg p-3 mt-3">
|
||||
<p class="font-semibold text-base-content">{deleteItem?.DepartmentName}</p>
|
||||
<p class="text-sm text-base-content/60">Code: {deleteItem?.DepartmentCode}</p>
|
||||
<p class="font-semibold text-base-content">{deleteItem?.DeptName || deleteItem?.DepartmentName}</p>
|
||||
<p class="text-sm text-base-content/60">Code: {deleteItem?.DeptCode || deleteItem?.DepartmentCode}</p>
|
||||
</div>
|
||||
<p class="text-sm text-error mt-4">This action cannot be undone.</p>
|
||||
</div>
|
||||
|
||||
@ -80,7 +80,7 @@
|
||||
function openEditModal(row) {
|
||||
modalMode = 'edit';
|
||||
formData = {
|
||||
DisciplineID: row.DisciplineID,
|
||||
DisciplineID: row.id || row.DisciplineID,
|
||||
DisciplineCode: row.DisciplineCode,
|
||||
DisciplineName: row.DisciplineName,
|
||||
Parent: row.Parent || null,
|
||||
@ -124,7 +124,8 @@
|
||||
async function handleDelete() {
|
||||
deleting = true;
|
||||
try {
|
||||
await deleteDiscipline(deleteItem.DisciplineID);
|
||||
const disciplineId = deleteItem.id || deleteItem.DisciplineID;
|
||||
await deleteDiscipline(disciplineId);
|
||||
toastSuccess('Discipline deleted successfully');
|
||||
deleteConfirmOpen = false;
|
||||
deleteItem = null;
|
||||
@ -264,8 +265,8 @@
|
||||
bind:value={formData.Parent}
|
||||
>
|
||||
<option value={null}>None (Top-level discipline)</option>
|
||||
{#each items.filter((d) => d.DisciplineID !== formData.DisciplineID) as discipline}
|
||||
<option value={discipline.DisciplineID}>{discipline.DisciplineName}</option>
|
||||
{#each items.filter((d) => (d.id || d.DisciplineID) !== formData.DisciplineID) as discipline}
|
||||
<option value={discipline.id || discipline.DisciplineID}>{discipline.DisciplineName}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<span class="label-text-alt text-xs text-gray-500">Optional parent for hierarchical structure</span>
|
||||
|
||||
@ -1,5 +1,12 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import {
|
||||
fetchSites,
|
||||
createSite,
|
||||
updateSite,
|
||||
deleteSite,
|
||||
fetchAccounts,
|
||||
} from '$lib/api/organization.js';
|
||||
import { success as toastSuccess, error as toastError } from '$lib/utils/toast.js';
|
||||
import DataTable from '$lib/components/DataTable.svelte';
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
@ -7,6 +14,7 @@
|
||||
|
||||
let loading = $state(false);
|
||||
let items = $state([]);
|
||||
let accounts = $state([]);
|
||||
let modalOpen = $state(false);
|
||||
let modalMode = $state('create');
|
||||
let saving = $state(false);
|
||||
@ -14,6 +22,7 @@
|
||||
SiteID: null,
|
||||
SiteCode: '',
|
||||
SiteName: '',
|
||||
AccountID: null,
|
||||
});
|
||||
let deleteConfirmOpen = $state(false);
|
||||
let deleteItem = $state(null);
|
||||
@ -23,17 +32,29 @@
|
||||
const columns = [
|
||||
{ key: 'SiteCode', label: 'Code', class: 'font-medium' },
|
||||
{ key: 'SiteName', label: 'Name' },
|
||||
{ key: 'AccountName', label: 'Account', class: 'w-48' },
|
||||
{ key: 'actions', label: 'Actions', class: 'w-32 text-center' },
|
||||
];
|
||||
|
||||
onMount(async () => {
|
||||
await loadAccounts();
|
||||
await loadItems();
|
||||
});
|
||||
|
||||
async function loadAccounts() {
|
||||
try {
|
||||
const response = await fetchAccounts();
|
||||
accounts = Array.isArray(response.data) ? response.data : [];
|
||||
} catch (err) {
|
||||
accounts = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function loadItems() {
|
||||
loading = true;
|
||||
try {
|
||||
items = [];
|
||||
const response = await fetchSites();
|
||||
items = Array.isArray(response.data) ? response.data : [];
|
||||
} catch (err) {
|
||||
toastError(err.message || 'Failed to load sites');
|
||||
items = [];
|
||||
@ -42,8 +63,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
const itemsWithAccountName = $derived(
|
||||
items.map((item) => ({
|
||||
...item,
|
||||
AccountName: accounts.find((a) => a.id === item.AccountID)?.AccountName || '-',
|
||||
}))
|
||||
);
|
||||
|
||||
const filteredItems = $derived(
|
||||
items.filter((item) => {
|
||||
itemsWithAccountName.filter((item) => {
|
||||
if (!searchQuery.trim()) return true;
|
||||
const query = searchQuery.toLowerCase();
|
||||
return (
|
||||
@ -55,16 +83,17 @@
|
||||
|
||||
function openCreateModal() {
|
||||
modalMode = 'create';
|
||||
formData = { SiteID: null, SiteCode: '', SiteName: '' };
|
||||
formData = { SiteID: null, SiteCode: '', SiteName: '', AccountID: null };
|
||||
modalOpen = true;
|
||||
}
|
||||
|
||||
function openEditModal(row) {
|
||||
modalMode = 'edit';
|
||||
formData = {
|
||||
SiteID: row.SiteID,
|
||||
SiteID: row.id || row.SiteID,
|
||||
SiteCode: row.SiteCode,
|
||||
SiteName: row.SiteName,
|
||||
AccountID: row.AccountID,
|
||||
};
|
||||
modalOpen = true;
|
||||
}
|
||||
@ -81,7 +110,13 @@
|
||||
|
||||
saving = true;
|
||||
try {
|
||||
toastSuccess('Site saved successfully (API integration pending)');
|
||||
if (modalMode === 'create') {
|
||||
await createSite(formData);
|
||||
toastSuccess('Site created successfully');
|
||||
} else {
|
||||
await updateSite(formData);
|
||||
toastSuccess('Site updated successfully');
|
||||
}
|
||||
modalOpen = false;
|
||||
await loadItems();
|
||||
} catch (err) {
|
||||
@ -99,7 +134,9 @@
|
||||
async function handleDelete() {
|
||||
deleting = true;
|
||||
try {
|
||||
toastSuccess('Site deleted successfully (API integration pending)');
|
||||
const siteId = deleteItem.id || deleteItem.SiteID;
|
||||
await deleteSite(siteId);
|
||||
toastSuccess('Site deleted successfully');
|
||||
deleteConfirmOpen = false;
|
||||
deleteItem = null;
|
||||
await loadItems();
|
||||
@ -228,6 +265,22 @@
|
||||
<span class="label-text-alt text-xs text-gray-500">Display name for this site</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label" for="account">
|
||||
<span class="label-text text-sm font-medium">Account</span>
|
||||
</label>
|
||||
<select
|
||||
id="account"
|
||||
class="select select-sm select-bordered w-full"
|
||||
bind:value={formData.AccountID}
|
||||
>
|
||||
<option value={null}>Select account...</option>
|
||||
{#each accounts as account}
|
||||
<option value={account.id}>{account.AccountName}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<span class="label-text-alt text-xs text-gray-500">The account this site belongs to</span>
|
||||
</div>
|
||||
</form>
|
||||
{#snippet footer()}
|
||||
<button class="btn btn-ghost" onclick={() => (modalOpen = false)} type="button">Cancel</button>
|
||||
|
||||
@ -1,5 +1,13 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import {
|
||||
fetchWorkstations,
|
||||
createWorkstation,
|
||||
updateWorkstation,
|
||||
deleteWorkstation,
|
||||
fetchSites,
|
||||
fetchDepartments,
|
||||
} from '$lib/api/organization.js';
|
||||
import { success as toastSuccess, error as toastError } from '$lib/utils/toast.js';
|
||||
import DataTable from '$lib/components/DataTable.svelte';
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
@ -7,6 +15,8 @@
|
||||
|
||||
let loading = $state(false);
|
||||
let items = $state([]);
|
||||
let sites = $state([]);
|
||||
let departments = $state([]);
|
||||
let modalOpen = $state(false);
|
||||
let modalMode = $state('create');
|
||||
let saving = $state(false);
|
||||
@ -14,6 +24,8 @@
|
||||
WorkstationID: null,
|
||||
WorkstationCode: '',
|
||||
WorkstationName: '',
|
||||
SiteID: null,
|
||||
DepartmentID: null,
|
||||
});
|
||||
let deleteConfirmOpen = $state(false);
|
||||
let deleteItem = $state(null);
|
||||
@ -23,17 +35,40 @@
|
||||
const columns = [
|
||||
{ key: 'WorkstationCode', label: 'Code', class: 'font-medium' },
|
||||
{ key: 'WorkstationName', label: 'Name' },
|
||||
{ key: 'SiteName', label: 'Site', class: 'w-40' },
|
||||
{ key: 'DepartmentName', label: 'Department', class: 'w-40' },
|
||||
{ key: 'actions', label: 'Actions', class: 'w-32 text-center' },
|
||||
];
|
||||
|
||||
onMount(async () => {
|
||||
await loadSites();
|
||||
await loadDepartments();
|
||||
await loadItems();
|
||||
});
|
||||
|
||||
async function loadSites() {
|
||||
try {
|
||||
const response = await fetchSites();
|
||||
sites = Array.isArray(response.data) ? response.data : [];
|
||||
} catch (err) {
|
||||
sites = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDepartments() {
|
||||
try {
|
||||
const response = await fetchDepartments();
|
||||
departments = Array.isArray(response.data) ? response.data : [];
|
||||
} catch (err) {
|
||||
departments = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function loadItems() {
|
||||
loading = true;
|
||||
try {
|
||||
items = [];
|
||||
const response = await fetchWorkstations();
|
||||
items = Array.isArray(response.data) ? response.data : [];
|
||||
} catch (err) {
|
||||
toastError(err.message || 'Failed to load workstations');
|
||||
items = [];
|
||||
@ -42,8 +77,16 @@
|
||||
}
|
||||
}
|
||||
|
||||
const itemsWithNames = $derived(
|
||||
items.map((item) => ({
|
||||
...item,
|
||||
SiteName: sites.find((s) => s.id === item.SiteID || s.SiteID === item.SiteID)?.SiteName || '-',
|
||||
DepartmentName: departments.find((d) => d.id === item.DepartmentID || d.DepartmentID === item.DepartmentID)?.DeptName || departments.find((d) => d.id === item.DepartmentID || d.DepartmentID === item.DepartmentID)?.DepartmentName || '-',
|
||||
}))
|
||||
);
|
||||
|
||||
const filteredItems = $derived(
|
||||
items.filter((item) => {
|
||||
itemsWithNames.filter((item) => {
|
||||
if (!searchQuery.trim()) return true;
|
||||
const query = searchQuery.toLowerCase();
|
||||
return (
|
||||
@ -55,16 +98,18 @@
|
||||
|
||||
function openCreateModal() {
|
||||
modalMode = 'create';
|
||||
formData = { WorkstationID: null, WorkstationCode: '', WorkstationName: '' };
|
||||
formData = { WorkstationID: null, WorkstationCode: '', WorkstationName: '', SiteID: null, DepartmentID: null };
|
||||
modalOpen = true;
|
||||
}
|
||||
|
||||
function openEditModal(row) {
|
||||
modalMode = 'edit';
|
||||
formData = {
|
||||
WorkstationID: row.WorkstationID,
|
||||
WorkstationID: row.id || row.WorkstationID,
|
||||
WorkstationCode: row.WorkstationCode,
|
||||
WorkstationName: row.WorkstationName,
|
||||
SiteID: row.SiteID,
|
||||
DepartmentID: row.DepartmentID,
|
||||
};
|
||||
modalOpen = true;
|
||||
}
|
||||
@ -81,7 +126,13 @@
|
||||
|
||||
saving = true;
|
||||
try {
|
||||
toastSuccess('Workstation saved successfully (API integration pending)');
|
||||
if (modalMode === 'create') {
|
||||
await createWorkstation(formData);
|
||||
toastSuccess('Workstation created successfully');
|
||||
} else {
|
||||
await updateWorkstation(formData);
|
||||
toastSuccess('Workstation updated successfully');
|
||||
}
|
||||
modalOpen = false;
|
||||
await loadItems();
|
||||
} catch (err) {
|
||||
@ -99,7 +150,9 @@
|
||||
async function handleDelete() {
|
||||
deleting = true;
|
||||
try {
|
||||
toastSuccess('Workstation deleted successfully (API integration pending)');
|
||||
const wsId = deleteItem.id || deleteItem.WorkstationID;
|
||||
await deleteWorkstation(wsId);
|
||||
toastSuccess('Workstation deleted successfully');
|
||||
deleteConfirmOpen = false;
|
||||
deleteItem = null;
|
||||
await loadItems();
|
||||
@ -228,6 +281,42 @@
|
||||
<span class="label-text-alt text-xs text-gray-500">Display name for this workstation</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="form-control">
|
||||
<label class="label" for="site">
|
||||
<span class="label-text text-sm font-medium">Site</span>
|
||||
<span class="label-text-alt text-xs text-error">*</span>
|
||||
</label>
|
||||
<select
|
||||
id="site"
|
||||
class="select select-sm select-bordered w-full"
|
||||
bind:value={formData.SiteID}
|
||||
required
|
||||
>
|
||||
<option value={null}>Select site...</option>
|
||||
{#each sites as site}
|
||||
<option value={site.id || site.SiteID}>{site.SiteName}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<span class="label-text-alt text-xs text-gray-500">The site this workstation belongs to</span>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label" for="department">
|
||||
<span class="label-text text-sm font-medium">Department</span>
|
||||
</label>
|
||||
<select
|
||||
id="department"
|
||||
class="select select-sm select-bordered w-full"
|
||||
bind:value={formData.DepartmentID}
|
||||
>
|
||||
<option value={null}>Select department...</option>
|
||||
{#each departments.filter((d) => !formData.SiteID || d.SiteID === formData.SiteID) as dept}
|
||||
<option value={dept.id || dept.DepartmentID}>{dept.DeptName || dept.DepartmentName}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<span class="label-text-alt text-xs text-gray-500">The department this workstation belongs to</span>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{#snippet footer()}
|
||||
<button class="btn btn-ghost" onclick={() => (modalOpen = false)} type="button">Cancel</button>
|
||||
|
||||
@ -110,13 +110,32 @@
|
||||
deleting = true;
|
||||
try {
|
||||
if (deleteGroupMode && deleteItem) {
|
||||
// For delete, we need the TestMapID
|
||||
// Since the list doesn't return TestMapID, we need to fetch it first
|
||||
// This is a limitation of the current API - it should return TestMapID in the list
|
||||
toastError('Delete functionality requires TestMapID which is not available in the list. Please contact administrator.');
|
||||
// TODO: Implement once API returns TestMapID or provides delete by host/client endpoint
|
||||
/**
|
||||
* API LIMITATION: TestMap delete requires TestMapID
|
||||
*
|
||||
* The current API list endpoint does not return TestMapID in the grouped
|
||||
* response. This is a known limitation. To implement group deletion,
|
||||
* the API needs to either:
|
||||
* 1. Return TestMapID in the list response, OR
|
||||
* 2. Provide a delete endpoint that accepts HostID/ClientID pair
|
||||
*
|
||||
* Current workaround: Users must delete individual mappings through
|
||||
* the edit modal instead of group deletion.
|
||||
*/
|
||||
const errorMessage = 'Delete functionality requires TestMapID which is not available in the list view. ' +
|
||||
'This is an API limitation - the list endpoint does not return TestMapID for grouped items. ' +
|
||||
'Please use the edit modal to delete individual mappings, or contact your administrator.';
|
||||
|
||||
console.warn('[TestMap Delete] API Limitation:', {
|
||||
reason: 'TestMapID not available in grouped list response',
|
||||
attemptedItem: deleteItem,
|
||||
suggestion: 'Delete individual mappings through edit modal',
|
||||
requires: 'API enhancement to return TestMapID or provide delete by host/client endpoint'
|
||||
});
|
||||
|
||||
toastError(errorMessage);
|
||||
} else if (deleteItem?.TestMapID) {
|
||||
// Delete single mapping (fallback)
|
||||
// Delete single mapping (fallback when TestMapID is available)
|
||||
await deleteTestMap(deleteItem.TestMapID);
|
||||
toastSuccess('Test mapping deleted successfully');
|
||||
deleteConfirmOpen = false;
|
||||
|
||||
375
src/routes/(app)/master-data/users/+page.svelte
Normal file
375
src/routes/(app)/master-data/users/+page.svelte
Normal file
@ -0,0 +1,375 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { Plus, Trash2, RefreshCw, Search, Users } from 'lucide-svelte';
|
||||
import {
|
||||
fetchUsers,
|
||||
fetchUser,
|
||||
createUser,
|
||||
updateUser,
|
||||
deleteUser
|
||||
} from '$lib/api/users.js';
|
||||
import { success as toastSuccess, error as toastError } from '$lib/utils/toast.js';
|
||||
import { auth } from '$lib/stores/auth.js';
|
||||
import DataTable from '$lib/components/DataTable.svelte';
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
import UserFormModal from './UserFormModal.svelte';
|
||||
|
||||
// Search state
|
||||
let searchFilters = $state({
|
||||
search: '',
|
||||
role: '',
|
||||
status: ''
|
||||
});
|
||||
|
||||
// List state
|
||||
let loading = $state(false);
|
||||
let users = $state([]);
|
||||
let currentPage = $state(1);
|
||||
let perPage = $state(20);
|
||||
let totalItems = $state(0);
|
||||
let totalPages = $state(1);
|
||||
|
||||
// Modal states
|
||||
let userForm = $state({
|
||||
open: false,
|
||||
user: null,
|
||||
loading: false
|
||||
});
|
||||
|
||||
let deleteModal = $state({
|
||||
open: false,
|
||||
user: null
|
||||
});
|
||||
|
||||
// Load users on mount
|
||||
onMount(() => {
|
||||
loadUsers();
|
||||
});
|
||||
|
||||
async function loadUsers() {
|
||||
loading = true;
|
||||
|
||||
try {
|
||||
const params = {
|
||||
page: currentPage,
|
||||
perPage
|
||||
};
|
||||
|
||||
// Add filters
|
||||
if (searchFilters.search.trim()) {
|
||||
params.search = searchFilters.search.trim();
|
||||
}
|
||||
if (searchFilters.role) {
|
||||
params.role = searchFilters.role;
|
||||
}
|
||||
if (searchFilters.status) {
|
||||
params.status = searchFilters.status;
|
||||
}
|
||||
|
||||
const response = await fetchUsers(params);
|
||||
users = Array.isArray(response.data) ? response.data : [];
|
||||
|
||||
if (response.pagination) {
|
||||
totalItems = response.pagination.total || 0;
|
||||
totalPages = Math.ceil(totalItems / perPage) || 1;
|
||||
} else {
|
||||
totalItems = users.length;
|
||||
totalPages = 1;
|
||||
}
|
||||
} catch (err) {
|
||||
toastError(err.message || 'Failed to load users');
|
||||
users = [];
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSearch() {
|
||||
currentPage = 1;
|
||||
await loadUsers();
|
||||
}
|
||||
|
||||
function handleClear() {
|
||||
searchFilters = {
|
||||
search: '',
|
||||
role: '',
|
||||
status: ''
|
||||
};
|
||||
currentPage = 1;
|
||||
loadUsers();
|
||||
}
|
||||
|
||||
function handlePageChange(newPage) {
|
||||
if (newPage >= 1 && newPage <= totalPages) {
|
||||
currentPage = newPage;
|
||||
loadUsers();
|
||||
}
|
||||
}
|
||||
|
||||
// CRUD Operations
|
||||
function openCreateModal() {
|
||||
userForm = { open: true, user: null, loading: false };
|
||||
}
|
||||
|
||||
async function openEditModal(user) {
|
||||
userForm = { open: true, user: null, loading: true };
|
||||
try {
|
||||
const response = await fetchUser(user.UserID);
|
||||
userForm = {
|
||||
...userForm,
|
||||
user: response.data || user,
|
||||
loading: false
|
||||
};
|
||||
} catch (err) {
|
||||
toastError(err.message || 'Failed to load user details');
|
||||
userForm = {
|
||||
...userForm,
|
||||
user: user,
|
||||
loading: false
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveUser(formData) {
|
||||
userForm.loading = true;
|
||||
|
||||
try {
|
||||
if (userForm.user) {
|
||||
// Update existing user
|
||||
await updateUser(userForm.user.UserID, formData);
|
||||
toastSuccess('User updated successfully');
|
||||
} else {
|
||||
// Create new user
|
||||
await createUser(formData);
|
||||
toastSuccess('User created successfully');
|
||||
}
|
||||
|
||||
userForm = { open: false, user: null, loading: false };
|
||||
await loadUsers();
|
||||
} catch (err) {
|
||||
toastError(err.message || 'Failed to save user');
|
||||
userForm.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(user) {
|
||||
// Prevent self-deletion
|
||||
if (user.UserID === $auth.user?.id) {
|
||||
toastError('You cannot delete your own account');
|
||||
return;
|
||||
}
|
||||
deleteModal = { open: true, user };
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!deleteModal.user?.UserID) return;
|
||||
|
||||
// Double-check self-deletion prevention
|
||||
if (deleteModal.user.UserID === $auth.user?.id) {
|
||||
toastError('You cannot delete your own account');
|
||||
deleteModal = { open: false, user: null };
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteUser(deleteModal.user.UserID);
|
||||
toastSuccess('User deleted successfully');
|
||||
deleteModal = { open: false, user: null };
|
||||
await loadUsers();
|
||||
} catch (err) {
|
||||
toastError(err.message || 'Failed to delete user');
|
||||
}
|
||||
}
|
||||
|
||||
function handleRefresh() {
|
||||
loadUsers();
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ key: 'Username', label: 'Username', class: 'w-32' },
|
||||
{ key: 'Name', label: 'Name', class: 'w-40' },
|
||||
{ key: 'Email', label: 'Email', class: 'w-48' },
|
||||
{ key: 'Role', label: 'Role', class: 'w-24' },
|
||||
{ key: 'Status', label: 'Status', class: 'w-24' },
|
||||
{ key: 'actions', label: 'Actions', class: 'w-32 text-center' }
|
||||
];
|
||||
</script>
|
||||
|
||||
<div class="h-[calc(100vh-4rem)] flex flex-col p-4 gap-3">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<Users class="w-8 h-8 text-primary" />
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-800">User Management</h1>
|
||||
<p class="text-sm text-gray-600">Manage system users and roles</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class="btn btn-ghost btn-sm"
|
||||
onclick={handleRefresh}
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshCw class="w-4 h-4 mr-1" />
|
||||
Refresh
|
||||
</button>
|
||||
<button class="btn btn-primary btn-sm" onclick={openCreateModal}>
|
||||
<Plus class="w-4 h-4 mr-1" />
|
||||
New User
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search Bar -->
|
||||
<div class="bg-base-100 p-4 rounded-lg shadow-sm border border-base-200">
|
||||
<div class="flex flex-wrap gap-3 items-end">
|
||||
<label class="form-control flex-1 min-w-[200px]">
|
||||
<span class="label-text text-xs text-gray-500 mb-1">Search</span>
|
||||
<label class="input input-sm input-bordered flex items-center gap-2 w-full">
|
||||
<Search class="w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
class="grow bg-transparent outline-none"
|
||||
placeholder="Search by username, email, or name..."
|
||||
bind:value={searchFilters.search}
|
||||
onkeydown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
/>
|
||||
</label>
|
||||
</label>
|
||||
|
||||
<label class="form-control w-40">
|
||||
<span class="label-text text-xs text-gray-500 mb-1">Role</span>
|
||||
<select class="select select-sm select-bordered w-full" bind:value={searchFilters.role}>
|
||||
<option value="">All Roles</option>
|
||||
<option value="admin">Admin</option>
|
||||
<option value="technician">Technician</option>
|
||||
<option value="physician">Physician</option>
|
||||
<option value="nurse">Nurse</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="form-control w-40">
|
||||
<span class="label-text text-xs text-gray-500 mb-1">Status</span>
|
||||
<select class="select select-sm select-bordered w-full" bind:value={searchFilters.status}>
|
||||
<option value="">All Status</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-primary btn-sm" onclick={handleSearch}>
|
||||
<Search class="w-4 h-4 mr-1" />
|
||||
Search
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-sm" onclick={handleClear}>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User List -->
|
||||
<div class="flex-1 bg-base-100 rounded-lg shadow border border-base-200 overflow-hidden">
|
||||
<DataTable
|
||||
{columns}
|
||||
data={users}
|
||||
{loading}
|
||||
emptyMessage="No users found"
|
||||
hover={true}
|
||||
bordered={false}
|
||||
>
|
||||
{#snippet cell({ column, row, value })}
|
||||
{#if column.key === 'Name'}
|
||||
{[row.NameFirst, row.NameLast].filter(Boolean).join(' ') || '-'}
|
||||
{:else if column.key === 'Status'}
|
||||
<span class="badge badge-sm {value === 'active' ? 'badge-success' : 'badge-ghost'}">
|
||||
{value || 'Inactive'}
|
||||
</span>
|
||||
{:else if column.key === 'actions'}
|
||||
<div class="flex justify-center gap-1">
|
||||
<button
|
||||
class="btn btn-xs btn-ghost"
|
||||
onclick={() => openEditModal(row)}
|
||||
title="Edit user"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-xs btn-ghost text-error"
|
||||
onclick={() => confirmDelete(row)}
|
||||
title="Delete user"
|
||||
disabled={row.UserID === $auth.user?.id}
|
||||
>
|
||||
<Trash2 class="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
{value || '-'}
|
||||
{/if}
|
||||
{/snippet}
|
||||
</DataTable>
|
||||
|
||||
<!-- Pagination -->
|
||||
{#if totalPages > 1}
|
||||
<div class="border-t border-base-200 p-3 flex justify-between items-center">
|
||||
<span class="text-sm text-gray-500">
|
||||
Showing {(currentPage - 1) * perPage + 1} - {Math.min(currentPage * perPage, totalItems)} of {totalItems}
|
||||
</span>
|
||||
<div class="join">
|
||||
<button
|
||||
class="join-item btn btn-sm"
|
||||
onclick={() => handlePageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
«
|
||||
</button>
|
||||
<button class="join-item btn btn-sm">Page {currentPage} of {totalPages}</button>
|
||||
<button
|
||||
class="join-item btn btn-sm"
|
||||
onclick={() => handlePageChange(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
>
|
||||
»
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User Form Modal -->
|
||||
<UserFormModal
|
||||
bind:open={userForm.open}
|
||||
user={userForm.user}
|
||||
loading={userForm.loading}
|
||||
onSave={handleSaveUser}
|
||||
onCancel={() => userForm = { open: false, user: null, loading: false }}
|
||||
/>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<Modal bind:open={deleteModal.open} title="Confirm Delete" size="sm">
|
||||
<div class="py-2">
|
||||
<p>
|
||||
Delete user
|
||||
<strong>{deleteModal.user?.Username}</strong>?
|
||||
</p>
|
||||
<p class="text-sm text-error mt-2">This action cannot be undone.</p>
|
||||
</div>
|
||||
{#snippet footer()}
|
||||
<button
|
||||
class="btn btn-ghost btn-sm"
|
||||
onclick={() => deleteModal.open = false}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-error btn-sm"
|
||||
onclick={handleDelete}
|
||||
>
|
||||
<Trash2 class="w-4 h-4 mr-1" />
|
||||
Delete
|
||||
</button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
292
src/routes/(app)/master-data/users/UserFormModal.svelte
Normal file
292
src/routes/(app)/master-data/users/UserFormModal.svelte
Normal file
@ -0,0 +1,292 @@
|
||||
<script>
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
import { User, Mail, Lock, Shield, Building } from 'lucide-svelte';
|
||||
|
||||
/** @type {{
|
||||
* open: boolean,
|
||||
* user: Object | null,
|
||||
* loading: boolean,
|
||||
* onSave: (data: Object) => void,
|
||||
* onCancel: () => void
|
||||
* }} */
|
||||
let {
|
||||
open = $bindable(false),
|
||||
user = null,
|
||||
loading = false,
|
||||
onSave,
|
||||
onCancel
|
||||
} = $props();
|
||||
|
||||
// Form state
|
||||
let formData = $state({
|
||||
Username: '',
|
||||
Email: '',
|
||||
Password: '',
|
||||
ConfirmPassword: '',
|
||||
NameFirst: '',
|
||||
NameLast: '',
|
||||
Role: 'technician',
|
||||
Department: '',
|
||||
Status: 'active'
|
||||
});
|
||||
|
||||
let formLoading = $state(false);
|
||||
let formError = $state('');
|
||||
|
||||
const roleOptions = [
|
||||
{ value: 'admin', label: 'Administrator' },
|
||||
{ value: 'technician', label: 'Lab Technician' },
|
||||
{ value: 'physician', label: 'Physician' },
|
||||
{ value: 'nurse', label: 'Nurse' },
|
||||
{ value: 'staff', label: 'Staff' }
|
||||
];
|
||||
|
||||
// Reset form when modal opens
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
if (user) {
|
||||
// Edit mode - populate form (don't include password)
|
||||
formData = {
|
||||
Username: user.Username || '',
|
||||
Email: user.Email || '',
|
||||
Password: '',
|
||||
ConfirmPassword: '',
|
||||
NameFirst: user.NameFirst || '',
|
||||
NameLast: user.NameLast || '',
|
||||
Role: user.Role || 'technician',
|
||||
Department: user.Department || '',
|
||||
Status: user.Status || 'active'
|
||||
};
|
||||
} else {
|
||||
// Create mode - reset form
|
||||
formData = {
|
||||
Username: '',
|
||||
Email: '',
|
||||
Password: '',
|
||||
ConfirmPassword: '',
|
||||
NameFirst: '',
|
||||
NameLast: '',
|
||||
Role: 'technician',
|
||||
Department: '',
|
||||
Status: 'active'
|
||||
};
|
||||
}
|
||||
formError = '';
|
||||
}
|
||||
});
|
||||
|
||||
function validateForm() {
|
||||
formError = '';
|
||||
|
||||
if (!formData.Username?.trim()) {
|
||||
formError = 'Username is required';
|
||||
return false;
|
||||
}
|
||||
if (!formData.Email?.trim()) {
|
||||
formError = 'Email is required';
|
||||
return false;
|
||||
}
|
||||
if (!user && !formData.Password) {
|
||||
formError = 'Password is required for new users';
|
||||
return false;
|
||||
}
|
||||
if (formData.Password && formData.Password !== formData.ConfirmPassword) {
|
||||
formError = 'Passwords do not match';
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
if (!validateForm()) return;
|
||||
|
||||
formLoading = true;
|
||||
|
||||
// Prepare data for submission
|
||||
const submitData = {
|
||||
Username: formData.Username,
|
||||
Email: formData.Email,
|
||||
NameFirst: formData.NameFirst,
|
||||
NameLast: formData.NameLast,
|
||||
Role: formData.Role,
|
||||
Department: formData.Department,
|
||||
Status: formData.Status
|
||||
};
|
||||
|
||||
// Only include password if it's set
|
||||
if (formData.Password) {
|
||||
submitData.Password = formData.Password;
|
||||
}
|
||||
|
||||
onSave?.(submitData);
|
||||
formLoading = false;
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
formError = '';
|
||||
onCancel?.();
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal
|
||||
bind:open
|
||||
title={user ? 'Edit User' : 'New User'}
|
||||
size="lg"
|
||||
onClose={handleClose}
|
||||
>
|
||||
<div class="space-y-4">
|
||||
{#if formError}
|
||||
<div class="alert alert-error alert-sm">
|
||||
<span>{formError}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<!-- Username -->
|
||||
<label class="form-control">
|
||||
<span class="label-text text-sm">
|
||||
Username <span class="text-error">*</span>
|
||||
</span>
|
||||
<label class="input input-sm input-bordered flex items-center gap-2 mt-1">
|
||||
<User class="w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
class="grow bg-transparent outline-none"
|
||||
placeholder="Enter username..."
|
||||
bind:value={formData.Username}
|
||||
disabled={!!user}
|
||||
/>
|
||||
</label>
|
||||
</label>
|
||||
|
||||
<!-- Email -->
|
||||
<label class="form-control">
|
||||
<span class="label-text text-sm">
|
||||
Email <span class="text-error">*</span>
|
||||
</span>
|
||||
<label class="input input-sm input-bordered flex items-center gap-2 mt-1">
|
||||
<Mail class="w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="email"
|
||||
class="grow bg-transparent outline-none"
|
||||
placeholder="Enter email..."
|
||||
bind:value={formData.Email}
|
||||
/>
|
||||
</label>
|
||||
</label>
|
||||
|
||||
<!-- First Name -->
|
||||
<label class="form-control">
|
||||
<span class="label-text text-sm">First Name</span>
|
||||
<input
|
||||
type="text"
|
||||
class="input input-sm input-bordered mt-1 w-full"
|
||||
placeholder="First name..."
|
||||
bind:value={formData.NameFirst}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<!-- Last Name -->
|
||||
<label class="form-control">
|
||||
<span class="label-text text-sm">Last Name</span>
|
||||
<input
|
||||
type="text"
|
||||
class="input input-sm input-bordered mt-1 w-full"
|
||||
placeholder="Last name..."
|
||||
bind:value={formData.NameLast}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<!-- Password (only for new users or when changing) -->
|
||||
<label class="form-control">
|
||||
<span class="label-text text-sm">
|
||||
{#if user}
|
||||
New Password (leave blank to keep current)
|
||||
{:else}
|
||||
Password <span class="text-error">*</span>
|
||||
{/if}
|
||||
</span>
|
||||
<label class="input input-sm input-bordered flex items-center gap-2 mt-1">
|
||||
<Lock class="w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="password"
|
||||
class="grow bg-transparent outline-none"
|
||||
placeholder={user ? 'New password...' : 'Enter password...'}
|
||||
bind:value={formData.Password}
|
||||
/>
|
||||
</label>
|
||||
</label>
|
||||
|
||||
<!-- Confirm Password -->
|
||||
<label class="form-control">
|
||||
<span class="label-text text-sm">Confirm Password</span>
|
||||
<label class="input input-sm input-bordered flex items-center gap-2 mt-1">
|
||||
<Lock class="w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="password"
|
||||
class="grow bg-transparent outline-none"
|
||||
placeholder="Confirm password..."
|
||||
bind:value={formData.ConfirmPassword}
|
||||
/>
|
||||
</label>
|
||||
</label>
|
||||
|
||||
<!-- Role -->
|
||||
<label class="form-control">
|
||||
<span class="label-text text-sm">Role</span>
|
||||
<label class="input input-sm input-bordered flex items-center gap-2 mt-1">
|
||||
<Shield class="w-4 h-4 text-gray-400" />
|
||||
<select class="grow bg-transparent outline-none" bind:value={formData.Role}>
|
||||
{#each roleOptions as option}
|
||||
<option value={option.value}>{option.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
</label>
|
||||
|
||||
<!-- Department -->
|
||||
<label class="form-control">
|
||||
<span class="label-text text-sm">Department</span>
|
||||
<label class="input input-sm input-bordered flex items-center gap-2 mt-1">
|
||||
<Building class="w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
class="grow bg-transparent outline-none"
|
||||
placeholder="Department..."
|
||||
bind:value={formData.Department}
|
||||
/>
|
||||
</label>
|
||||
</label>
|
||||
|
||||
<!-- Status -->
|
||||
<label class="form-control">
|
||||
<span class="label-text text-sm">Status</span>
|
||||
<select class="select select-sm select-bordered mt-1 w-full" bind:value={formData.Status}>
|
||||
<option value="active">Active</option>
|
||||
<option value="inactive">Inactive</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#snippet footer()}
|
||||
<button
|
||||
class="btn btn-ghost btn-sm"
|
||||
onclick={handleClose}
|
||||
disabled={loading || formLoading}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-primary btn-sm"
|
||||
onclick={handleSubmit}
|
||||
disabled={loading || formLoading}
|
||||
>
|
||||
{#if loading || formLoading}
|
||||
<span class="loading loading-spinner loading-sm mr-2"></span>
|
||||
{/if}
|
||||
{user ? 'Update' : 'Create'}
|
||||
</button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
@ -9,8 +9,9 @@
|
||||
deleteOrder,
|
||||
updateOrderStatus
|
||||
} from '$lib/api/orders.js';
|
||||
import { success as toastSuccess, error as toastError } from '$lib/utils/toast.js';
|
||||
import OrderSearchBar from './OrderSearchBar.svelte';
|
||||
import { success as toastSuccess, error as toastError } from '$lib/utils/toast.js';
|
||||
import { printSpecimenLabel } from '$lib/utils/barcode.js';
|
||||
import OrderSearchBar from './OrderSearchBar.svelte';
|
||||
import OrderList from './OrderList.svelte';
|
||||
import OrderFormModal from './OrderFormModal.svelte';
|
||||
import OrderDetailModal from './OrderDetailModal.svelte';
|
||||
@ -240,8 +241,7 @@
|
||||
}
|
||||
|
||||
function handlePrintBarcode(order) {
|
||||
// TODO: Implement barcode printing
|
||||
toastSuccess(`Print barcode for order: ${order.OrderID}`);
|
||||
printSpecimenLabel(order);
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@ -5,8 +5,9 @@
|
||||
import { fetchPatients } from '$lib/api/patients.js';
|
||||
import { fetchTests } from '$lib/api/tests.js';
|
||||
import { fetchVisitsByPatient } from '$lib/api/visits.js';
|
||||
import { fetchDisciplines } from '$lib/api/organization.js';
|
||||
import { success as toastSuccess, error as toastError } from '$lib/utils/toast.js';
|
||||
import { User, FlaskConical, Building2, Hash, FileText, AlertCircle, Plus, X, Search, Beaker } from 'lucide-svelte';
|
||||
import { User, Building2, Hash, FileText, AlertCircle, Plus, X, Search, Beaker, Filter, ChevronRight, ChevronLeft, Trash2 } from 'lucide-svelte';
|
||||
|
||||
/** @type {{
|
||||
* open: boolean,
|
||||
@ -51,11 +52,24 @@
|
||||
let showTestSearch = $state(false);
|
||||
let isInitialized = $state(false);
|
||||
|
||||
// Dual-column test selection state
|
||||
let availableTests = $state([]);
|
||||
let disciplines = $state([]);
|
||||
let selectedDiscipline = $state('');
|
||||
let testsLoading = $state(false);
|
||||
let testsPage = $state(1);
|
||||
let testsHasMore = $state(false);
|
||||
let testsPerPage = $state(100);
|
||||
|
||||
// Reset form when modal opens (only when transitioning from closed to open)
|
||||
$effect(() => {
|
||||
if (open && !isInitialized) {
|
||||
isInitialized = true;
|
||||
|
||||
// Load disciplines and initial tests for dual-column layout
|
||||
loadDisciplines();
|
||||
loadInitialTests();
|
||||
|
||||
if (order) {
|
||||
// Edit mode - populate form
|
||||
formData = {
|
||||
@ -103,8 +117,9 @@
|
||||
formError = '';
|
||||
showPatientSearch = false;
|
||||
testSearchQuery = '';
|
||||
testSearchResults = [];
|
||||
showTestSearch = false;
|
||||
selectedDiscipline = '';
|
||||
testsPage = 1;
|
||||
testsHasMore = false;
|
||||
} else if (!open) {
|
||||
// Reset initialization flag when modal closes
|
||||
isInitialized = false;
|
||||
@ -158,47 +173,147 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function searchTests() {
|
||||
if (!testSearchQuery.trim()) return;
|
||||
|
||||
formLoading = true;
|
||||
async function loadDisciplines() {
|
||||
try {
|
||||
const query = testSearchQuery.trim();
|
||||
const response = await fetchTests({
|
||||
TestSiteCode: query,
|
||||
TestSiteName: query,
|
||||
perPage: 10
|
||||
});
|
||||
testSearchResults = response.data || [];
|
||||
showTestSearch = true;
|
||||
const response = await fetchDisciplines();
|
||||
disciplines = response.data || [];
|
||||
} catch (err) {
|
||||
console.error('Failed to load disciplines:', err);
|
||||
disciplines = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function loadInitialTests() {
|
||||
testsLoading = true;
|
||||
try {
|
||||
const params = {
|
||||
perPage: testsPerPage,
|
||||
page: 1,
|
||||
Requestable: 1
|
||||
};
|
||||
|
||||
if (selectedDiscipline) {
|
||||
params.DisciplineID = selectedDiscipline;
|
||||
}
|
||||
|
||||
const response = await fetchTests(params);
|
||||
availableTests = response.data || [];
|
||||
testsPage = 1;
|
||||
const pagination = response.pagination || {};
|
||||
testsHasMore = pagination.currentPage < pagination.lastPage;
|
||||
} catch (err) {
|
||||
console.error('Failed to load tests:', err);
|
||||
availableTests = [];
|
||||
testsHasMore = false;
|
||||
} finally {
|
||||
testsLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMoreTests() {
|
||||
if (!testsHasMore || testsLoading) return;
|
||||
await searchTests(true);
|
||||
}
|
||||
|
||||
async function searchTests(loadMore = false) {
|
||||
if (!loadMore && !testSearchQuery.trim()) {
|
||||
await loadInitialTests();
|
||||
return;
|
||||
}
|
||||
|
||||
testsLoading = true;
|
||||
const currentPage = loadMore ? testsPage + 1 : 1;
|
||||
|
||||
try {
|
||||
const params = {
|
||||
perPage: testsPerPage,
|
||||
page: currentPage,
|
||||
Requestable: 1
|
||||
};
|
||||
|
||||
if (testSearchQuery.trim()) {
|
||||
params.search = testSearchQuery.trim();
|
||||
}
|
||||
|
||||
if (selectedDiscipline) {
|
||||
params.DisciplineID = selectedDiscipline;
|
||||
}
|
||||
|
||||
const response = await fetchTests(params);
|
||||
const newTests = response.data || [];
|
||||
const pagination = response.pagination || {};
|
||||
|
||||
if (loadMore) {
|
||||
availableTests = [...availableTests, ...newTests];
|
||||
testsPage = currentPage;
|
||||
} else {
|
||||
availableTests = newTests;
|
||||
testsPage = 1;
|
||||
}
|
||||
|
||||
testsHasMore = pagination.currentPage < pagination.lastPage;
|
||||
} catch (err) {
|
||||
toastError('Failed to search tests');
|
||||
testSearchResults = [];
|
||||
if (!loadMore) {
|
||||
availableTests = [];
|
||||
}
|
||||
} finally {
|
||||
formLoading = false;
|
||||
testsLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function addTest(test) {
|
||||
// Check for duplicates
|
||||
if (formData.Tests.some(t => t.TestSiteID === test.TestSiteID)) {
|
||||
formError = 'Test already added';
|
||||
return;
|
||||
}
|
||||
|
||||
formData.Tests = [...formData.Tests, {
|
||||
TestSiteID: test.TestSiteID,
|
||||
TestSiteCode: test.TestSiteCode,
|
||||
TestSiteName: test.TestSiteName
|
||||
TestSiteName: test.TestSiteName,
|
||||
DisciplineID: test.DisciplineID
|
||||
}];
|
||||
testSearchQuery = '';
|
||||
testSearchResults = [];
|
||||
showTestSearch = false;
|
||||
formError = '';
|
||||
}
|
||||
|
||||
function removeTest(index) {
|
||||
formData.Tests = formData.Tests.filter((_, i) => i !== index);
|
||||
function removeTest(testSiteId) {
|
||||
formData.Tests = formData.Tests.filter(t => t.TestSiteID !== testSiteId);
|
||||
}
|
||||
|
||||
function addAllTests() {
|
||||
const newTests = availableTests.filter(test =>
|
||||
!formData.Tests.some(t => t.TestSiteID === test.TestSiteID)
|
||||
);
|
||||
|
||||
formData.Tests = [...formData.Tests, ...newTests.map(test => ({
|
||||
TestSiteID: test.TestSiteID,
|
||||
TestSiteCode: test.TestSiteCode,
|
||||
TestSiteName: test.TestSiteName,
|
||||
DisciplineID: test.DisciplineID
|
||||
}))];
|
||||
formError = '';
|
||||
}
|
||||
|
||||
function removeAllTests() {
|
||||
formData.Tests = [];
|
||||
}
|
||||
|
||||
function isTestSelected(testSiteId) {
|
||||
return formData.Tests.some(t => t.TestSiteID === testSiteId);
|
||||
}
|
||||
|
||||
function toggleTest(test) {
|
||||
if (isTestSelected(test.TestSiteID)) {
|
||||
removeTest(test.TestSiteID);
|
||||
} else {
|
||||
addTest(test);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDisciplineChange() {
|
||||
// Don't clear search query - apply filter with existing search
|
||||
await searchTests(false);
|
||||
}
|
||||
|
||||
function validateForm() {
|
||||
@ -246,6 +361,11 @@
|
||||
}
|
||||
|
||||
const priorityOptions = Object.values(ORDER_PRIORITY);
|
||||
|
||||
function getDisciplineName(disciplineId) {
|
||||
const discipline = disciplines.find(d => d.DisciplineID === disciplineId);
|
||||
return discipline ? discipline.DisciplineCode : '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="tall-modal">
|
||||
@ -460,8 +580,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RIGHT COLUMN: Tests -->
|
||||
<div class="bg-base-200 rounded-lg p-4 flex flex-col">
|
||||
<!-- RIGHT COLUMN: Tests (Dual-Column Transfer Layout) -->
|
||||
<div class="bg-base-200 rounded-lg p-4 flex flex-col h-full">
|
||||
<h4 class="text-sm font-semibold mb-3 flex items-center gap-2 text-gray-700">
|
||||
<Beaker class="w-4 h-4" />
|
||||
Tests <span class="text-error">*</span>
|
||||
@ -470,83 +590,171 @@
|
||||
{/if}
|
||||
</h4>
|
||||
|
||||
<!-- Add Test Search -->
|
||||
<div class="space-y-2 mb-3">
|
||||
<div class="flex gap-2">
|
||||
<div class="input input-sm input-bordered flex items-center gap-2 flex-1 bg-base-100 focus-within:input-primary">
|
||||
<Search class="w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
class="grow bg-transparent outline-none"
|
||||
placeholder="Search test by code or name..."
|
||||
bind:value={testSearchQuery}
|
||||
onkeydown={(e) => e.key === 'Enter' && searchTests()}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-primary btn-sm"
|
||||
onclick={searchTests}
|
||||
disabled={formLoading || !testSearchQuery.trim()}
|
||||
>
|
||||
{#if formLoading}
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
{:else}
|
||||
<Search class="w-4 h-4" />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if showTestSearch && testSearchResults.length > 0}
|
||||
<div class="border border-base-300 rounded-lg max-h-40 overflow-auto bg-base-100">
|
||||
{#each testSearchResults as test (test.TestSiteID)}
|
||||
<button
|
||||
class="w-full text-left p-2 hover:bg-base-200 border-b border-base-200 last:border-b-0"
|
||||
onclick={() => addTest(test)}
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs font-mono bg-primary/10 text-primary px-1.5 py-0.5 rounded">{test.TestSiteCode}</span>
|
||||
<span class="text-sm font-medium">{test.TestSiteName}</span>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if showTestSearch}
|
||||
<p class="text-sm text-gray-500">No tests found</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Tests List -->
|
||||
<div class="flex-1 min-h-[200px] max-h-[400px] overflow-auto">
|
||||
{#if formData.Tests.length > 0}
|
||||
<div class="space-y-1">
|
||||
{#each formData.Tests as test, index (test.TestSiteID)}
|
||||
<div class="flex items-center justify-between p-2 bg-base-100 rounded border border-base-300 hover:border-primary/50 transition-colors">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
{#if test.TestSiteCode}
|
||||
<span class="text-xs font-mono bg-primary/10 text-primary px-1.5 py-0.5 rounded shrink-0">{test.TestSiteCode}</span>
|
||||
{:else}
|
||||
<span class="text-xs font-mono bg-base-200 px-1.5 py-0.5 rounded shrink-0">ID:{test.TestSiteID}</span>
|
||||
{/if}
|
||||
<span class="text-sm truncate" title={test.TestSiteName || ''}>
|
||||
{test.TestSiteName || `Test #${test.TestSiteID}`}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-ghost btn-xs btn-square text-error hover:bg-error/10 shrink-0"
|
||||
onclick={() => removeTest(index)}
|
||||
<!-- Dual-Column Layout -->
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<!-- LEFT COLUMN: Available Tests -->
|
||||
<div class="bg-base-100 rounded-lg border border-base-300 flex flex-col">
|
||||
<!-- Header -->
|
||||
<div class="p-2 border-b border-base-300 bg-base-200/50 rounded-t-lg">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<span class="text-xs font-medium text-gray-600">Available Tests</span>
|
||||
<span class="text-xs text-gray-400">{availableTests.length}</span>
|
||||
</div>
|
||||
<!-- Search and Filter -->
|
||||
<div class="space-y-1.5">
|
||||
<div class="input input-xs input-bordered flex items-center gap-2 focus-within:input-primary bg-base-100">
|
||||
<Search class="w-3 h-3 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
class="grow bg-transparent outline-none text-xs"
|
||||
placeholder="Press Enter to search..."
|
||||
bind:value={testSearchQuery}
|
||||
onkeydown={(e) => e.key === 'Enter' && searchTests()}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-1">
|
||||
<select
|
||||
class="select select-xs select-bordered flex-1 text-xs"
|
||||
bind:value={selectedDiscipline}
|
||||
onchange={handleDisciplineChange}
|
||||
>
|
||||
<X class="w-3 h-3" />
|
||||
<option value="">All Disciplines</option>
|
||||
{#each disciplines as discipline (discipline.DisciplineID)}
|
||||
<option value={discipline.DisciplineID}>{discipline.DisciplineCode}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<button
|
||||
class="btn btn-xs btn-ghost"
|
||||
onclick={() => searchTests()}
|
||||
disabled={testsLoading}
|
||||
title="Search"
|
||||
>
|
||||
{#if testsLoading}
|
||||
<span class="loading loading-spinner loading-xs"></span>
|
||||
{:else}
|
||||
<Search class="w-3 h-3" />
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-col items-center justify-center h-full text-gray-400 py-8">
|
||||
<Beaker class="w-10 h-10 mb-2 opacity-50" />
|
||||
<p class="text-sm">No tests added yet</p>
|
||||
<p class="text-xs mt-1">Search and select tests above</p>
|
||||
|
||||
<!-- Available Tests List - With Scroll -->
|
||||
<div class="p-1.5 max-h-[300px] overflow-y-auto">
|
||||
{#if testsLoading && availableTests.length === 0}
|
||||
<div class="flex items-center justify-center py-8">
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
</div>
|
||||
{:else if availableTests.length === 0}
|
||||
<div class="flex flex-col items-center justify-center text-gray-400 py-8">
|
||||
<Search class="w-6 h-6 mb-1 opacity-50" />
|
||||
<p class="text-xs">No tests found</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-0.5">
|
||||
{#each availableTests as test (test.TestSiteID)}
|
||||
{@const isSelected = isTestSelected(test.TestSiteID)}
|
||||
<button
|
||||
class="w-full text-left p-1.5 rounded hover:bg-base-200 border transition-all flex items-center gap-2 group"
|
||||
class:bg-base-200="{!isSelected}"
|
||||
class:border-transparent="{!isSelected}"
|
||||
class:border-base-300="{!isSelected}"
|
||||
class:bg-primary-50="{isSelected}"
|
||||
class:border-primary="{isSelected}"
|
||||
class:border-opacity-30="{isSelected}"
|
||||
onclick={() => toggleTest(test)}
|
||||
>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-1.5">
|
||||
{#if test.TestSiteCode}
|
||||
<span class="text-xs font-mono bg-primary/10 text-primary px-1 py-0 rounded shrink-0">{test.TestSiteCode}</span>
|
||||
{:else}
|
||||
<span class="text-xs font-mono bg-base-200 px-1 py-0 rounded shrink-0">ID:{test.TestSiteID}</span>
|
||||
{/if}
|
||||
{#if test.DisciplineID}
|
||||
<span class="text-[10px] text-gray-400 shrink-0">{getDisciplineName(test.DisciplineID)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-xs truncate text-gray-600 mt-0.5">{test.TestSiteName || `Test #${test.TestSiteID}`}</p>
|
||||
</div>
|
||||
{#if isTestSelected(test.TestSiteID)}
|
||||
<ChevronRight class="w-3.5 h-3.5 text-primary shrink-0" />
|
||||
{:else}
|
||||
<Plus class="w-3.5 h-3.5 text-gray-400 group-hover:text-primary shrink-0" />
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Show More Button -->
|
||||
{#if testsHasMore}
|
||||
<div class="mt-2 pt-2 border-t border-base-200">
|
||||
<button
|
||||
class="btn btn-xs btn-ghost btn-block text-xs"
|
||||
onclick={loadMoreTests}
|
||||
disabled={testsLoading}
|
||||
>
|
||||
{#if testsLoading}
|
||||
<span class="loading loading-spinner loading-xs"></span>
|
||||
Loading...
|
||||
{:else}
|
||||
Show more tests
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- RIGHT COLUMN: Selected Tests -->
|
||||
<div class="bg-base-100 rounded-lg border border-base-300 flex flex-col">
|
||||
<!-- Header -->
|
||||
<div class="p-2 border-b border-base-300 bg-base-200/50 rounded-t-lg">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs font-medium text-gray-600">Selected</span>
|
||||
<span class="badge badge-xs badge-primary">{formData.Tests.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Selected Tests List - With Scroll -->
|
||||
<div class="p-1.5 max-h-[300px] overflow-y-auto flex-1">
|
||||
{#if formData.Tests.length === 0}
|
||||
<div class="flex flex-col items-center justify-center text-gray-400 py-8">
|
||||
<Beaker class="w-6 h-6 mb-1 opacity-50" />
|
||||
<p class="text-xs">No tests selected</p>
|
||||
<p class="text-[10px] mt-0.5 text-gray-300">Click tests to add</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-0.5">
|
||||
{#each formData.Tests as test, index (test.TestSiteID)}
|
||||
<div
|
||||
class="flex items-center gap-2 p-1.5 bg-primary/5 rounded border border-primary/20"
|
||||
>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-1.5">
|
||||
{#if test.TestSiteCode}
|
||||
<span class="text-xs font-mono bg-primary/10 text-primary px-1 py-0 rounded shrink-0">{test.TestSiteCode}</span>
|
||||
{:else}
|
||||
<span class="text-xs font-mono bg-base-200 px-1 py-0 rounded shrink-0">ID:{test.TestSiteID}</span>
|
||||
{/if}
|
||||
<span class="text-[10px] text-gray-400">#{index + 1}</span>
|
||||
</div>
|
||||
<p class="text-xs truncate text-gray-700 mt-0.5">{test.TestSiteName || `Test #${test.TestSiteID}`}</p>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-ghost btn-xs btn-square text-error hover:bg-error/10 shrink-0 p-0.5 h-auto min-h-0"
|
||||
onclick={() => removeTest(test.TestSiteID)}
|
||||
title="Remove"
|
||||
>
|
||||
<X class="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
<script>
|
||||
import { Edit2, Trash2, Eye, ChevronLeft, ChevronRight, FileText } from 'lucide-svelte';
|
||||
import { Edit2, Trash2, Eye, ChevronLeft, ChevronRight, FileText, AlertTriangle, CheckCircle2, FlaskConical } from 'lucide-svelte';
|
||||
import DataTable from '$lib/components/DataTable.svelte';
|
||||
import { getStatusInfo, getPriorityInfo } from '$lib/api/orders.js';
|
||||
|
||||
@ -49,9 +49,10 @@
|
||||
{ key: 'PatientName', label: 'Patient Name', class: 'w-40' },
|
||||
{ key: 'OrderStatus', label: 'Status', class: 'w-28' },
|
||||
{ key: 'Priority', label: 'Priority', class: 'w-24' },
|
||||
{ key: 'OrderDate', label: 'Order Date', class: 'w-32' },
|
||||
{ key: 'OrderingProvider', label: 'Provider', class: 'w-32' },
|
||||
{ key: 'actions', label: 'Actions', class: 'w-28 text-center' }
|
||||
{ key: 'Results', label: 'Results', class: 'w-32' },
|
||||
{ key: 'OrderDate', label: 'Order Date', class: 'w-28' },
|
||||
{ key: 'OrderingProvider', label: 'Provider', class: 'w-28' },
|
||||
{ key: 'actions', label: 'Actions', class: 'w-24 text-center' }
|
||||
];
|
||||
|
||||
function formatDate(dateString) {
|
||||
@ -64,6 +65,27 @@
|
||||
});
|
||||
}
|
||||
|
||||
function calculateResultSummary(tests) {
|
||||
if (!tests || tests.length === 0) {
|
||||
return { total: 0, pending: 0, abnormal: 0, complete: false };
|
||||
}
|
||||
|
||||
const total = tests.length;
|
||||
const pending = tests.filter(t => !t.Result || t.Result === '').length;
|
||||
const abnormal = tests.filter(t => {
|
||||
if (!t.Result || !t.Low || !t.High) return false;
|
||||
const val = parseFloat(t.Result);
|
||||
return !isNaN(val) && (val < t.Low || val > t.High);
|
||||
}).length;
|
||||
|
||||
return {
|
||||
total,
|
||||
pending,
|
||||
abnormal,
|
||||
complete: pending === 0
|
||||
};
|
||||
}
|
||||
|
||||
function handlePreviousPage() {
|
||||
if (currentPage > 1) {
|
||||
onPageChange(currentPage - 1);
|
||||
@ -109,6 +131,31 @@
|
||||
<span class="truncate block max-w-[120px]" title={value}>
|
||||
{value || '-'}
|
||||
</span>
|
||||
{:else if column.key === 'Results'}
|
||||
{@const summary = calculateResultSummary(row.Tests)}
|
||||
<div class="flex items-center gap-2">
|
||||
<FlaskConical class="w-3.5 h-3.5 text-base-content/40" />
|
||||
<span class="text-xs">
|
||||
{#if summary.total === 0}
|
||||
<span class="text-base-content/40">No tests</span>
|
||||
{:else if summary.complete}
|
||||
<span class="text-success flex items-center gap-1">
|
||||
<CheckCircle2 class="w-3 h-3" />
|
||||
{summary.total} complete
|
||||
{#if summary.abnormal > 0}
|
||||
<span class="text-error">({summary.abnormal} abnormal)</span>
|
||||
{/if}
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-warning">
|
||||
{summary.total - summary.pending}/{summary.total} done
|
||||
{#if summary.abnormal > 0}
|
||||
<span class="text-error">({summary.abnormal} abnormal)</span>
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
{:else if column.key === 'actions'}
|
||||
<div class="flex items-center justify-center gap-1">
|
||||
<button
|
||||
|
||||
489
src/routes/(app)/patient-results/+page.svelte
Normal file
489
src/routes/(app)/patient-results/+page.svelte
Normal file
@ -0,0 +1,489 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import {
|
||||
Search,
|
||||
Calendar,
|
||||
User,
|
||||
FileText,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
AlertTriangle,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Minus,
|
||||
Printer,
|
||||
Download,
|
||||
History
|
||||
} from 'lucide-svelte';
|
||||
import { fetchResultsByPatient } from '$lib/api/results.js';
|
||||
import { fetchPatients } from '$lib/api/patients.js';
|
||||
import { error as toastError, success as toastSuccess } from '$lib/utils/toast.js';
|
||||
|
||||
// Search state
|
||||
let patientId = $state('');
|
||||
let patientName = $state('');
|
||||
let startDate = $state('');
|
||||
let endDate = $state('');
|
||||
|
||||
// Results state
|
||||
let loading = $state(false);
|
||||
let results = $state([]);
|
||||
let patientInfo = $state(null);
|
||||
let currentPage = $state(1);
|
||||
let perPage = $state(20);
|
||||
let totalItems = $state(0);
|
||||
|
||||
// Grouped results by test
|
||||
let groupedResults = $state({});
|
||||
|
||||
async function handleSearch() {
|
||||
if (!patientId.trim() && !patientName.trim()) {
|
||||
toastError('Please enter Patient ID or Name');
|
||||
return;
|
||||
}
|
||||
|
||||
loading = true;
|
||||
currentPage = 1;
|
||||
results = [];
|
||||
patientInfo = null;
|
||||
groupedResults = {};
|
||||
|
||||
try {
|
||||
// First, search for patient to get ID
|
||||
let targetPatientId = patientId.trim();
|
||||
|
||||
if (!targetPatientId && patientName.trim()) {
|
||||
// Search by name first
|
||||
const patientResponse = await fetchPatients({ Name: patientName.trim(), perPage: 1 });
|
||||
if (patientResponse.data && patientResponse.data.length > 0) {
|
||||
targetPatientId = patientResponse.data[0].PatientID;
|
||||
patientInfo = patientResponse.data[0];
|
||||
} else {
|
||||
toastError('Patient not found');
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
} else if (targetPatientId) {
|
||||
// Get patient details
|
||||
const patientResponse = await fetchPatients({ PatientID: targetPatientId, perPage: 1 });
|
||||
if (patientResponse.data && patientResponse.data.length > 0) {
|
||||
patientInfo = patientResponse.data[0];
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetPatientId) {
|
||||
toastError('Patient not found');
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Now fetch results
|
||||
const params = {
|
||||
patient_id: targetPatientId,
|
||||
page: currentPage,
|
||||
per_page: perPage
|
||||
};
|
||||
|
||||
if (startDate) params.start_date = startDate;
|
||||
if (endDate) params.end_date = endDate;
|
||||
|
||||
const response = await fetchResultsByPatient(targetPatientId, currentPage, perPage);
|
||||
|
||||
if (response.status === 'success') {
|
||||
results = response.data || [];
|
||||
totalItems = response.total || results.length;
|
||||
groupResultsByTest();
|
||||
} else {
|
||||
results = [];
|
||||
totalItems = 0;
|
||||
}
|
||||
} catch (err) {
|
||||
toastError(err.message || 'Failed to load results');
|
||||
results = [];
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function groupResultsByTest() {
|
||||
const grouped = {};
|
||||
|
||||
results.forEach(result => {
|
||||
const testCode = result.TestSiteCode || result.TestSiteName;
|
||||
if (!grouped[testCode]) {
|
||||
grouped[testCode] = {
|
||||
testCode: result.TestSiteCode,
|
||||
testName: result.TestSiteName,
|
||||
unit: result.Unit1,
|
||||
refLow: result.Low,
|
||||
refHigh: result.High,
|
||||
results: []
|
||||
};
|
||||
}
|
||||
grouped[testCode].results.push({
|
||||
resultId: result.ResultID,
|
||||
value: result.Result,
|
||||
flag: result.AbnormalFlag,
|
||||
orderId: result.OrderID,
|
||||
orderDate: result.OrderDate,
|
||||
enteredBy: result.EnteredBy,
|
||||
enteredDateTime: result.EnteredDateTime,
|
||||
refNumID: result.RefNumID
|
||||
});
|
||||
});
|
||||
|
||||
// Sort results within each group by date (newest first)
|
||||
Object.keys(grouped).forEach(key => {
|
||||
grouped[key].results.sort((a, b) => {
|
||||
return new Date(b.enteredDateTime || 0) - new Date(a.enteredDateTime || 0);
|
||||
});
|
||||
});
|
||||
|
||||
groupedResults = grouped;
|
||||
}
|
||||
|
||||
function handleClear() {
|
||||
patientId = '';
|
||||
patientName = '';
|
||||
startDate = '';
|
||||
endDate = '';
|
||||
results = [];
|
||||
patientInfo = null;
|
||||
groupedResults = {};
|
||||
totalItems = 0;
|
||||
}
|
||||
|
||||
function handlePageChange(newPage) {
|
||||
currentPage = newPage;
|
||||
handleSearch();
|
||||
}
|
||||
|
||||
function getTrendIndicator(current, previous) {
|
||||
if (!current || !previous) return null;
|
||||
const currVal = parseFloat(current);
|
||||
const prevVal = parseFloat(previous);
|
||||
if (isNaN(currVal) || isNaN(prevVal)) return null;
|
||||
|
||||
if (currVal > prevVal) return { icon: TrendingUp, color: 'text-success' };
|
||||
if (currVal < prevVal) return { icon: TrendingDown, color: 'text-error' };
|
||||
return { icon: Minus, color: 'text-base-content/40' };
|
||||
}
|
||||
|
||||
function getFlagBadge(flag) {
|
||||
if (flag === 'H') return { color: 'badge-error', label: 'High' };
|
||||
if (flag === 'L') return { color: 'badge-warning', label: 'Low' };
|
||||
return { color: 'badge-success', label: 'Normal' };
|
||||
}
|
||||
|
||||
function formatDate(dateString) {
|
||||
if (!dateString) return '-';
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
function formatDateTime(dateString) {
|
||||
if (!dateString) return '-';
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
function handlePrint() {
|
||||
window.print();
|
||||
}
|
||||
|
||||
const totalPages = $derived(Math.ceil(totalItems / perPage));
|
||||
const hasResults = $derived(Object.keys(groupedResults).length > 0);
|
||||
</script>
|
||||
|
||||
<div class="p-4 space-y-4">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between print:hidden">
|
||||
<h1 class="text-2xl font-bold text-primary flex items-center gap-2">
|
||||
<History class="w-6 h-6" />
|
||||
Patient Results History
|
||||
</h1>
|
||||
{#if hasResults}
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-sm btn-ghost" onclick={handlePrint}>
|
||||
<Printer class="w-4 h-4 mr-1" />
|
||||
Print
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Search Panel -->
|
||||
<div class="card bg-base-100 shadow compact-card print:hidden">
|
||||
<div class="card-body p-3">
|
||||
<div class="flex flex-wrap gap-3 items-end">
|
||||
<div class="form-control w-40">
|
||||
<span class="label-text text-xs mb-1">Patient ID</span>
|
||||
<label class="input input-sm input-bordered flex items-center gap-2">
|
||||
<User class="w-3 h-3 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
class="grow bg-transparent outline-none text-sm"
|
||||
placeholder="Enter ID..."
|
||||
bind:value={patientId}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-control w-48">
|
||||
<span class="label-text text-xs mb-1">Patient Name</span>
|
||||
<label class="input input-sm input-bordered flex items-center gap-2">
|
||||
<User class="w-3 h-3 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
class="grow bg-transparent outline-none text-sm"
|
||||
placeholder="Search by name..."
|
||||
bind:value={patientName}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-control w-36">
|
||||
<span class="label-text text-xs mb-1">Start Date</span>
|
||||
<label class="input input-sm input-bordered flex items-center gap-2">
|
||||
<Calendar class="w-3 h-3 text-gray-400" />
|
||||
<input
|
||||
type="date"
|
||||
class="grow bg-transparent outline-none text-sm"
|
||||
bind:value={startDate}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-control w-36">
|
||||
<span class="label-text text-xs mb-1">End Date</span>
|
||||
<label class="input input-sm input-bordered flex items-center gap-2">
|
||||
<Calendar class="w-3 h-3 text-gray-400" />
|
||||
<input
|
||||
type="date"
|
||||
class="grow bg-transparent outline-none text-sm"
|
||||
bind:value={endDate}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2 ml-auto">
|
||||
<button class="btn btn-sm btn-primary" onclick={handleSearch} disabled={loading}>
|
||||
{#if loading}
|
||||
<span class="loading loading-spinner loading-xs"></span>
|
||||
{:else}
|
||||
<Search class="w-4 h-4" />
|
||||
{/if}
|
||||
Search
|
||||
</button>
|
||||
<button class="btn btn-sm btn-ghost" onclick={handleClear}>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Patient Info -->
|
||||
{#if patientInfo}
|
||||
<div class="card bg-primary/5 border border-primary/20">
|
||||
<div class="card-body p-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-12 h-12 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<User class="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-lg font-bold">{patientInfo.PatientName || patientInfo.Name || 'Unknown'}</h2>
|
||||
<div class="text-sm text-base-content/60 flex gap-4">
|
||||
<span>ID: {patientInfo.PatientID || patientInfo.InternalPID}</span>
|
||||
{#if patientInfo.DateOfBirth}
|
||||
<span>DOB: {formatDate(patientInfo.DateOfBirth)}</span>
|
||||
{/if}
|
||||
{#if patientInfo.Gender}
|
||||
<span>Gender: {patientInfo.Gender}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-auto text-right">
|
||||
<div class="text-2xl font-bold text-primary">{Object.keys(groupedResults).length}</div>
|
||||
<div class="text-sm text-base-content/60">Tests</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Results Table -->
|
||||
{#if hasResults}
|
||||
<div class="card bg-base-100 shadow compact-card">
|
||||
<div class="card-body p-0">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-sm">
|
||||
<thead class="sticky top-0 bg-base-100 z-10">
|
||||
<tr class="bg-base-200">
|
||||
<th class="text-xs font-semibold w-20">Test Code</th>
|
||||
<th class="text-xs font-semibold w-40">Test Name</th>
|
||||
<th class="text-xs font-semibold w-20">Unit</th>
|
||||
<th class="text-xs font-semibold w-28">Reference</th>
|
||||
<th class="text-xs font-semibold w-24">Latest</th>
|
||||
<th class="text-xs font-semibold w-12 text-center">Flag</th>
|
||||
<th class="text-xs font-semibold w-20 text-center">Trend</th>
|
||||
<th class="text-xs font-semibold w-24">Previous</th>
|
||||
<th class="text-xs font-semibold w-28">Order Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each Object.entries(groupedResults) as [testCode, testData]}
|
||||
{@const latest = testData.results[0]}
|
||||
{@const previous = testData.results[1]}
|
||||
{@const flag = getFlagBadge(latest.flag)}
|
||||
{@const trend = getTrendIndicator(latest.value, previous?.value)}
|
||||
<tr class="hover:bg-base-200/30">
|
||||
<td class="text-xs font-mono">{testData.testCode}</td>
|
||||
<td class="text-xs font-medium">{testData.testName}</td>
|
||||
<td class="text-xs text-base-content/60">{testData.unit || '-'}</td>
|
||||
<td class="text-xs text-base-content/60">
|
||||
{#if testData.refLow !== null && testData.refHigh !== null}
|
||||
{testData.refLow} - {testData.refHigh}
|
||||
{:else}
|
||||
-
|
||||
{/if}
|
||||
</td>
|
||||
<td class="text-sm font-mono font-semibold">
|
||||
{latest.value || '-'}
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<span class="badge {flag.color} badge-sm">{flag.label}</span>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
{#if trend}
|
||||
<svelte:component this={trend.icon} class="w-4 h-4 {trend.color} inline" />
|
||||
{:else}
|
||||
<span class="text-base-content/30">-</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="text-xs text-base-content/60 font-mono">
|
||||
{previous?.value || '-'}
|
||||
</td>
|
||||
<td class="text-xs text-base-content/60">
|
||||
{formatDateTime(latest.enteredDateTime)}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{#if totalPages > 1}
|
||||
<div class="flex justify-center p-3 border-t border-base-200 print:hidden">
|
||||
<div class="join">
|
||||
<button
|
||||
class="join-item btn btn-sm"
|
||||
disabled={currentPage === 1}
|
||||
onclick={() => handlePageChange(currentPage - 1)}
|
||||
>
|
||||
<ChevronLeft class="w-4 h-4" />
|
||||
</button>
|
||||
<span class="join-item btn btn-sm btn-ghost">
|
||||
Page {currentPage} of {totalPages}
|
||||
</span>
|
||||
<button
|
||||
class="join-item btn btn-sm"
|
||||
disabled={currentPage >= totalPages}
|
||||
onclick={() => handlePageChange(currentPage + 1)}
|
||||
>
|
||||
<ChevronRight class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Detailed History per Test -->
|
||||
<div class="space-y-4 print:break-before-page">
|
||||
<h2 class="text-xl font-bold print:mt-8">Detailed History</h2>
|
||||
|
||||
{#each Object.entries(groupedResults) as [testCode, testData]}
|
||||
<div class="card bg-base-100 shadow compact-card print:border print:border-gray-300">
|
||||
<div class="card-body p-3">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="font-mono text-sm bg-base-200 px-2 py-1 rounded">{testData.testCode}</span>
|
||||
<span class="font-semibold">{testData.testName}</span>
|
||||
{#if testData.unit}
|
||||
<span class="text-sm text-base-content/60">({testData.unit})</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="text-sm text-base-content/60">
|
||||
{testData.results.length} result(s)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr class="bg-base-200/50">
|
||||
<th class="text-xs">Result</th>
|
||||
<th class="text-xs">Flag</th>
|
||||
<th class="text-xs">Order ID</th>
|
||||
<th class="text-xs">Date/Time</th>
|
||||
<th class="text-xs">Entered By</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each testData.results as result}
|
||||
{@const flag = getFlagBadge(result.flag)}
|
||||
<tr class="hover:bg-base-200/20">
|
||||
<td class="text-sm font-mono">{result.value || '-'}</td>
|
||||
<td>
|
||||
<span class="badge {flag.color} badge-sm">{flag.label}</span>
|
||||
</td>
|
||||
<td class="text-xs font-mono">{result.orderId}</td>
|
||||
<td class="text-xs">{formatDateTime(result.enteredDateTime)}</td>
|
||||
<td class="text-xs text-base-content/60">{result.enteredBy || '-'}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{:else if !loading && patientInfo}
|
||||
<div class="card bg-base-100 shadow compact-card">
|
||||
<div class="card-body p-8 text-center">
|
||||
<FileText class="w-12 h-12 mx-auto mb-2 opacity-30" />
|
||||
<p class="text-base-content/50">No results found for this patient</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@media print {
|
||||
.print\:hidden {
|
||||
display: none !important;
|
||||
}
|
||||
.print\:border {
|
||||
border: 1px solid #ccc !important;
|
||||
}
|
||||
.print\:break-before-page {
|
||||
break-before: page;
|
||||
}
|
||||
.print\:mt-8 {
|
||||
margin-top: 2rem !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -1,13 +1,15 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { fetchPatients, fetchPatient } from '$lib/api/patients.js';
|
||||
import { fetchPatients, fetchPatient, deletePatient } from '$lib/api/patients.js';
|
||||
import { fetchOrders, createOrder } from '$lib/api/orders.js';
|
||||
import { success as toastSuccess, error as toastError } from '$lib/utils/toast.js';
|
||||
import { printPatientWristband, printSpecimenLabel } from '$lib/utils/barcode.js';
|
||||
import PatientSearchBar from './PatientSearchBar.svelte';
|
||||
import PatientList from './PatientList.svelte';
|
||||
import OrderList from './OrderList.svelte';
|
||||
import PatientFormModal from './PatientFormModal.svelte';
|
||||
import OrderFormModal from '../orders/OrderFormModal.svelte';
|
||||
import OrderDetailModal from '../orders/OrderDetailModal.svelte';
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
import { Plus, Edit2, Trash2 } from 'lucide-svelte';
|
||||
|
||||
@ -50,6 +52,13 @@
|
||||
loading: false
|
||||
});
|
||||
|
||||
// View order modal state
|
||||
let viewOrderModal = $state({
|
||||
open: false,
|
||||
order: null,
|
||||
loading: false
|
||||
});
|
||||
|
||||
// Load patients on mount (empty on init)
|
||||
onMount(() => {
|
||||
// Don't auto-load - wait for search
|
||||
@ -168,8 +177,7 @@
|
||||
if (!deleteModal.patient?.InternalPID) return;
|
||||
|
||||
try {
|
||||
// TODO: Implement deletePatient API
|
||||
// await deletePatient(deleteModal.patient.InternalPID);
|
||||
await deletePatient(deleteModal.patient.InternalPID);
|
||||
toastSuccess('Patient deleted successfully');
|
||||
deleteModal = { open: false, patient: null };
|
||||
await handleSearch();
|
||||
@ -213,13 +221,11 @@
|
||||
}
|
||||
|
||||
function handleViewOrder(order) {
|
||||
// TODO: Implement order detail view
|
||||
toastSuccess(`View order: ${order.OrderNumber}`);
|
||||
viewOrderModal = { open: true, order, loading: false };
|
||||
}
|
||||
|
||||
function handlePrintBarcode(order) {
|
||||
// TODO: Implement barcode printing
|
||||
toastSuccess(`Print barcode for: ${order.OrderNumber}`);
|
||||
printSpecimenLabel(order);
|
||||
}
|
||||
|
||||
function handleRefreshOrders() {
|
||||
@ -227,6 +233,10 @@
|
||||
loadOrders(selectedPatient);
|
||||
}
|
||||
}
|
||||
|
||||
function handleCloseOrderDetail() {
|
||||
viewOrderModal = { open: false, order: null, loading: false };
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="h-[calc(100vh-4rem)] flex flex-col p-4 gap-3">
|
||||
@ -271,6 +281,7 @@
|
||||
{perPage}
|
||||
onPageChange={handlePageChange}
|
||||
onSelectPatient={handleShowOrders}
|
||||
onEditPatient={openEditModal}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -313,6 +324,14 @@
|
||||
loading={orderForm.loading}
|
||||
/>
|
||||
|
||||
<!-- Order Detail Modal -->
|
||||
<OrderDetailModal
|
||||
bind:open={viewOrderModal.open}
|
||||
order={viewOrderModal.order}
|
||||
loading={viewOrderModal.loading}
|
||||
onClose={handleCloseOrderDetail}
|
||||
/>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<Modal bind:open={deleteModal.open} title="Confirm Delete" size="sm">
|
||||
<div class="py-2">
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
<script>
|
||||
import { ChevronLeft, ChevronRight, Users } from 'lucide-svelte';
|
||||
import { ChevronLeft, ChevronRight, Users, Edit2 } from 'lucide-svelte';
|
||||
import DataTable from '$lib/components/DataTable.svelte';
|
||||
import { formatPatientName, formatSex, formatBirthdate } from '$lib/utils/patients.js';
|
||||
|
||||
@ -27,7 +27,8 @@
|
||||
* totalItems: number,
|
||||
* perPage: number,
|
||||
* onPageChange: (page: number) => void,
|
||||
* onSelectPatient: (patient: Patient) => void
|
||||
* onSelectPatient: (patient: Patient) => void,
|
||||
* onEditPatient?: (patient: Patient) => void
|
||||
* }} */
|
||||
let {
|
||||
patients = [],
|
||||
@ -38,14 +39,16 @@
|
||||
totalItems = 0,
|
||||
perPage = 20,
|
||||
onPageChange,
|
||||
onSelectPatient
|
||||
onSelectPatient,
|
||||
onEditPatient
|
||||
} = $props();
|
||||
|
||||
const columns = [
|
||||
{ key: 'PatientID', label: 'ID', class: 'font-medium w-20' },
|
||||
{ key: 'PatientID', label: 'ID', class: 'font-medium w-24' },
|
||||
{ key: 'FullName', label: 'Name', class: 'min-w-32' },
|
||||
{ key: 'SexLabel', label: 'Sex', class: 'w-12' },
|
||||
{ key: 'BirthdateFormatted', label: 'DOB', class: 'w-24' },
|
||||
{ key: 'BirthdateFormatted', label: 'DOB', class: 'w-28' },
|
||||
{ key: 'actions', label: '', class: 'w-12 text-center' },
|
||||
];
|
||||
|
||||
let displayPatients = $derived(
|
||||
@ -81,13 +84,26 @@
|
||||
onRowClick={onSelectPatient}
|
||||
>
|
||||
{#snippet cell({ column, row })}
|
||||
<span
|
||||
class="truncate block"
|
||||
class:font-semibold={selectedPatient?.InternalPID === row.InternalPID}
|
||||
class:text-primary={selectedPatient?.InternalPID === row.InternalPID}
|
||||
>
|
||||
{row[column.key]}
|
||||
</span>
|
||||
{#if column.key === 'actions'}
|
||||
<button
|
||||
class="btn btn-xs btn-ghost"
|
||||
title="Edit patient"
|
||||
onclick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEditPatient?.(row);
|
||||
}}
|
||||
>
|
||||
<Edit2 class="w-4 h-4" />
|
||||
</button>
|
||||
{:else}
|
||||
<span
|
||||
class="truncate block"
|
||||
class:font-semibold={selectedPatient?.InternalPID === row.InternalPID}
|
||||
class:text-primary={selectedPatient?.InternalPID === row.InternalPID}
|
||||
>
|
||||
{row[column.key]}
|
||||
</span>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</DataTable>
|
||||
</div>
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import {
|
||||
Search,
|
||||
FileText,
|
||||
@ -10,7 +10,8 @@
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
Download
|
||||
Download,
|
||||
Lock
|
||||
} from 'lucide-svelte';
|
||||
import { fetchOrders } from '$lib/api/orders.js';
|
||||
import { getReportUrl } from '$lib/api/reports.js';
|
||||
@ -30,9 +31,12 @@
|
||||
let filterPatientId = $state('');
|
||||
|
||||
// Modal state
|
||||
let selectedOrderId = $state(null);
|
||||
let selectedOrder = $state(null);
|
||||
let showReportModal = $state(false);
|
||||
|
||||
// Minimum status required to view/print reports
|
||||
const MIN_REPORT_STATUS = ['VER', 'REV', 'REP'];
|
||||
|
||||
const orderStatuses = [
|
||||
{ value: 'ORD', label: 'Ordered', color: 'badge-neutral' },
|
||||
{ value: 'SCH', label: 'Scheduled', color: 'badge-info' },
|
||||
@ -71,13 +75,18 @@
|
||||
}
|
||||
}
|
||||
|
||||
function handleViewReport(orderId) {
|
||||
selectedOrderId = orderId;
|
||||
function handleViewReport(order) {
|
||||
selectedOrder = order;
|
||||
showReportModal = true;
|
||||
}
|
||||
|
||||
function handlePrintReport(orderId) {
|
||||
const url = getReportUrl(orderId);
|
||||
function handlePrintReport(order) {
|
||||
if (!isReportAvailable(order.OrderStatus)) {
|
||||
toastError(`Reports can only be printed for orders with status: Verified, Reviewed, or Reported. Current status: ${getStatusLabel(order.OrderStatus)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const url = getReportUrl(order.OrderID);
|
||||
const printWindow = window.open(url, '_blank');
|
||||
if (printWindow) {
|
||||
printWindow.onload = () => {
|
||||
@ -86,6 +95,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
function isReportAvailable(status) {
|
||||
return MIN_REPORT_STATUS.includes(status);
|
||||
}
|
||||
|
||||
function handlePageChange(newPage) {
|
||||
currentPage = newPage;
|
||||
loadOrders();
|
||||
@ -238,16 +251,21 @@
|
||||
<td class="text-right">
|
||||
<div class="flex gap-1 justify-end">
|
||||
<button
|
||||
class="btn btn-ghost btn-xs text-primary"
|
||||
title="View Report"
|
||||
onclick={() => handleViewReport(order.OrderID)}
|
||||
class="btn btn-ghost btn-xs {isReportAvailable(order.OrderStatus) ? 'text-primary' : 'text-base-content/30'}"
|
||||
title={isReportAvailable(order.OrderStatus) ? 'View Report' : 'Report not available - Order must be Verified, Reviewed, or Reported'}
|
||||
onclick={() => handleViewReport(order)}
|
||||
>
|
||||
<Eye class="w-3 h-3" />
|
||||
{#if isReportAvailable(order.OrderStatus)}
|
||||
<Eye class="w-3 h-3" />
|
||||
{:else}
|
||||
<Lock class="w-3 h-3" />
|
||||
{/if}
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-ghost btn-xs"
|
||||
title="Print Report"
|
||||
onclick={() => handlePrintReport(order.OrderID)}
|
||||
class="btn btn-ghost btn-xs {isReportAvailable(order.OrderStatus) ? '' : 'text-base-content/30'}"
|
||||
title={isReportAvailable(order.OrderStatus) ? 'Print Report' : 'Cannot print - Order must be Verified, Reviewed, or Reported'}
|
||||
onclick={() => handlePrintReport(order)}
|
||||
disabled={!isReportAvailable(order.OrderStatus)}
|
||||
>
|
||||
<Printer class="w-3 h-3" />
|
||||
</button>
|
||||
@ -303,9 +321,10 @@
|
||||
</div>
|
||||
|
||||
<!-- Report Viewer Modal -->
|
||||
{#if showReportModal && selectedOrderId}
|
||||
{#if showReportModal && selectedOrder}
|
||||
<ReportViewerModal
|
||||
orderId={selectedOrderId}
|
||||
orderId={selectedOrder.OrderID}
|
||||
orderStatus={selectedOrder.OrderStatus}
|
||||
bind:open={showReportModal}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@ -1,17 +1,22 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { FileText, Printer, Download, X, ExternalLink } from 'lucide-svelte';
|
||||
import { FileText, Printer, Download, X, ExternalLink, RefreshCw, AlertTriangle, Lock } from 'lucide-svelte';
|
||||
import { getReportUrl } from '$lib/api/reports.js';
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
|
||||
let {
|
||||
orderId = $bindable(),
|
||||
orderStatus = $bindable(''),
|
||||
open = $bindable(false)
|
||||
} = $props();
|
||||
|
||||
let reportUrl = $state('');
|
||||
let loading = $state(true);
|
||||
let error = $state('');
|
||||
let retryCount = $state(0);
|
||||
const MAX_RETRIES = 3;
|
||||
|
||||
// Minimum status required to view report
|
||||
const MIN_REPORT_STATUS = ['VER', 'REV', 'REP'];
|
||||
|
||||
$effect(() => {
|
||||
if (open && orderId) {
|
||||
@ -23,14 +28,30 @@
|
||||
loading = true;
|
||||
error = '';
|
||||
|
||||
// Build report URL - use the direct API endpoint
|
||||
const baseUrl = PUBLIC_API_URL || 'http://localhost/clqms01';
|
||||
reportUrl = `${baseUrl}/api/reports/${orderId}`;
|
||||
|
||||
// Simulate loading delay to show spinner
|
||||
setTimeout(() => {
|
||||
// Validate order status
|
||||
if (!isReportAvailable()) {
|
||||
loading = false;
|
||||
}, 500);
|
||||
return;
|
||||
}
|
||||
|
||||
// Build report URL using config
|
||||
reportUrl = getReportUrl(orderId);
|
||||
}
|
||||
|
||||
function isReportAvailable() {
|
||||
return MIN_REPORT_STATUS.includes(orderStatus);
|
||||
}
|
||||
|
||||
function getStatusLabel(status) {
|
||||
const statusMap = {
|
||||
'ORD': 'Ordered',
|
||||
'SCH': 'Scheduled',
|
||||
'ANA': 'Analysis',
|
||||
'VER': 'Verified',
|
||||
'REV': 'Reviewed',
|
||||
'REP': 'Reported'
|
||||
};
|
||||
return statusMap[status] || status;
|
||||
}
|
||||
|
||||
function handlePrint() {
|
||||
@ -49,15 +70,31 @@
|
||||
function handleClose() {
|
||||
open = false;
|
||||
error = '';
|
||||
retryCount = 0;
|
||||
}
|
||||
|
||||
function handleIframeLoad() {
|
||||
loading = false;
|
||||
retryCount = 0;
|
||||
}
|
||||
|
||||
function handleIframeError() {
|
||||
loading = false;
|
||||
error = 'Failed to load report. The order may not have results yet.';
|
||||
|
||||
if (retryCount < MAX_RETRIES) {
|
||||
retryCount++;
|
||||
// Retry after a short delay
|
||||
setTimeout(() => {
|
||||
loadReport();
|
||||
}, 1000);
|
||||
} else {
|
||||
error = `Failed to load report after ${MAX_RETRIES} attempts. The order may not have results yet or the report service is unavailable.`;
|
||||
}
|
||||
}
|
||||
|
||||
function handleRetry() {
|
||||
retryCount = 0;
|
||||
loadReport();
|
||||
}
|
||||
</script>
|
||||
|
||||
@ -69,45 +106,80 @@
|
||||
<div class="flex items-center gap-2">
|
||||
<FileText class="w-5 h-5 text-primary" />
|
||||
<span class="font-semibold">Order: {orderId}</span>
|
||||
{#if orderStatus}
|
||||
<span class="badge badge-sm badge-outline">{getStatusLabel(orderStatus)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class="btn btn-sm btn-ghost"
|
||||
onclick={handleOpenInNewTab}
|
||||
title="Open in new tab"
|
||||
>
|
||||
<ExternalLink class="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm btn-ghost"
|
||||
onclick={handlePrint}
|
||||
title="Print report"
|
||||
>
|
||||
<Printer class="w-4 h-4" />
|
||||
</button>
|
||||
{#if isReportAvailable()}
|
||||
<button
|
||||
class="btn btn-sm btn-ghost"
|
||||
onclick={handleOpenInNewTab}
|
||||
title="Open in new tab"
|
||||
>
|
||||
<ExternalLink class="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-sm btn-ghost"
|
||||
onclick={handlePrint}
|
||||
title="Print report"
|
||||
>
|
||||
<Printer class="w-4 h-4" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Report Content -->
|
||||
<div class="flex-1 relative bg-white rounded-lg border border-base-200 overflow-hidden">
|
||||
{#if loading}
|
||||
{#if !isReportAvailable()}
|
||||
<!-- Status Validation Error -->
|
||||
<div class="absolute inset-0 flex items-center justify-center bg-base-100 p-8">
|
||||
<div class="text-center max-w-md">
|
||||
<div class="w-16 h-16 mx-auto mb-4 rounded-full bg-warning/10 flex items-center justify-center">
|
||||
<Lock class="w-8 h-8 text-warning" />
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-warning mb-2">Report Not Available</h3>
|
||||
<p class="text-base-content/60 mb-4">
|
||||
Reports can only be viewed for orders with status: <strong>Verified</strong>, <strong>Reviewed</strong>, or <strong>Reported</strong>.
|
||||
</p>
|
||||
<div class="flex items-center justify-center gap-2 text-sm">
|
||||
<span class="text-base-content/40">Current status:</span>
|
||||
<span class="badge badge-sm badge-error">{getStatusLabel(orderStatus)}</span>
|
||||
</div>
|
||||
<div class="mt-4 text-xs text-base-content/40">
|
||||
Status pipeline: ORD → SCH → ANA → <strong>VER</strong> → REV → REP
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else if loading}
|
||||
<div class="absolute inset-0 flex items-center justify-center bg-base-100">
|
||||
<div class="text-center">
|
||||
<span class="loading loading-spinner loading-lg text-primary mb-4"></span>
|
||||
<p class="text-base-content/60">Loading report...</p>
|
||||
{#if retryCount > 0}
|
||||
<p class="text-xs text-base-content/40 mt-2">Retry attempt {retryCount}/{MAX_RETRIES}...</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if error}
|
||||
{:else if error}
|
||||
<div class="absolute inset-0 flex items-center justify-center bg-base-100 p-8">
|
||||
<div class="text-center">
|
||||
<div class="text-center max-w-md">
|
||||
<div class="w-16 h-16 mx-auto mb-4 rounded-full bg-error/10 flex items-center justify-center">
|
||||
<FileText class="w-8 h-8 text-error" />
|
||||
<AlertTriangle class="w-8 h-8 text-error" />
|
||||
</div>
|
||||
<h3 class="text-lg font-semibold text-error mb-2">Report Error</h3>
|
||||
<p class="text-base-content/60 mb-4">{error}</p>
|
||||
<p class="text-sm text-base-content/40">Note: Reports are only available for orders that have been reported (REP status)</p>
|
||||
<div class="flex gap-2 justify-center">
|
||||
<button class="btn btn-primary btn-sm" onclick={handleRetry}>
|
||||
<RefreshCw class="w-4 h-4" />
|
||||
Retry
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-sm" onclick={handleOpenInNewTab}>
|
||||
<ExternalLink class="w-4 h-4" />
|
||||
Open in New Tab
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
@ -129,9 +201,16 @@
|
||||
<X class="w-4 h-4" />
|
||||
Close
|
||||
</button>
|
||||
<button class="btn btn-primary btn-sm" onclick={handlePrint}>
|
||||
<Printer class="w-4 h-4" />
|
||||
Print Report
|
||||
</button>
|
||||
{#if isReportAvailable()}
|
||||
<button class="btn btn-primary btn-sm" onclick={handlePrint}>
|
||||
<Printer class="w-4 h-4" />
|
||||
Print Report
|
||||
</button>
|
||||
{:else}
|
||||
<button class="btn btn-sm btn-disabled" disabled>
|
||||
<Lock class="w-4 h-4" />
|
||||
Not Available
|
||||
</button>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</Modal>
|
||||
|
||||
@ -1,5 +1,21 @@
|
||||
<script>
|
||||
import { FlaskConical, AlertTriangle, CheckCircle2, X, Save, User, FileText } from 'lucide-svelte';
|
||||
import {
|
||||
FlaskConical,
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
X,
|
||||
Save,
|
||||
User,
|
||||
FileText,
|
||||
TestTube,
|
||||
Calendar,
|
||||
Clock,
|
||||
Hash,
|
||||
MessageSquare,
|
||||
Copy,
|
||||
ChevronDown,
|
||||
ChevronUp
|
||||
} from 'lucide-svelte';
|
||||
import { updateResult } from '$lib/api/results.js';
|
||||
import { error as toastError, success as toastSuccess } from '$lib/utils/toast.js';
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
@ -15,6 +31,12 @@
|
||||
let formLoading = $state(false);
|
||||
let saveProgress = $state({ current: 0, total: 0 });
|
||||
|
||||
// Expandable rows for comments
|
||||
let expandedRows = $state(new Set());
|
||||
|
||||
// Keyboard shortcuts enabled
|
||||
let shortcutsEnabled = $state(true);
|
||||
|
||||
// Initialize results when order changes
|
||||
$effect(() => {
|
||||
if (order && open) {
|
||||
@ -35,14 +57,45 @@
|
||||
HighSign: test.HighSign,
|
||||
RefDisplay: test.RefDisplay,
|
||||
RefNumID: test.RefNumID,
|
||||
ResultStatus: test.ResultStatus || 'PRE',
|
||||
EnteredBy: test.EnteredBy || '',
|
||||
EnteredDateTime: test.EnteredDateTime || '',
|
||||
Comment: test.Comment || '',
|
||||
flag: calculateFlag(test.Result, test.Low, test.High),
|
||||
saved: false,
|
||||
error: null
|
||||
}));
|
||||
saveProgress = { current: 0, total: 0 };
|
||||
expandedRows = new Set();
|
||||
}
|
||||
});
|
||||
|
||||
// Keyboard shortcuts
|
||||
$effect(() => {
|
||||
if (!open) return;
|
||||
|
||||
function handleKeyDown(event) {
|
||||
if (!shortcutsEnabled) return;
|
||||
|
||||
// Ctrl+S: Save all
|
||||
if (event.ctrlKey && event.key === 's') {
|
||||
event.preventDefault();
|
||||
if (!formLoading && pendingCount !== results.length) {
|
||||
handleSaveAll();
|
||||
}
|
||||
}
|
||||
|
||||
// Escape: Close modal (only if not in input)
|
||||
if (event.key === 'Escape' && event.target.tagName !== 'INPUT' && event.target.tagName !== 'TEXTAREA') {
|
||||
event.preventDefault();
|
||||
handleClose();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
});
|
||||
|
||||
function calculateFlag(value, low, high) {
|
||||
if (!value || value === '') return null;
|
||||
|
||||
@ -77,6 +130,48 @@
|
||||
return '';
|
||||
}
|
||||
|
||||
function getResultStatusBadge(status) {
|
||||
const statusMap = {
|
||||
'PRE': { color: 'badge-info', label: 'Preliminary' },
|
||||
'FIN': { color: 'badge-success', label: 'Final' },
|
||||
'MOD': { color: 'badge-warning', label: 'Modified' },
|
||||
'COR': { color: 'badge-error', label: 'Corrected' }
|
||||
};
|
||||
return statusMap[status] || { color: 'badge-neutral', label: status };
|
||||
}
|
||||
|
||||
function formatDateTime(dateString) {
|
||||
if (!dateString) return '-';
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString();
|
||||
}
|
||||
|
||||
function toggleCommentRow(index) {
|
||||
if (expandedRows.has(index)) {
|
||||
expandedRows.delete(index);
|
||||
} else {
|
||||
expandedRows.add(index);
|
||||
}
|
||||
expandedRows = expandedRows; // Trigger reactivity
|
||||
}
|
||||
|
||||
function copyResultToClipboard(value) {
|
||||
navigator.clipboard.writeText(value).then(() => {
|
||||
toastSuccess('Result copied to clipboard');
|
||||
}).catch(() => {
|
||||
toastError('Failed to copy');
|
||||
});
|
||||
}
|
||||
|
||||
function copyPreviousResult(index) {
|
||||
if (index === 0) return;
|
||||
const prevResult = results[index - 1].Result;
|
||||
if (prevResult) {
|
||||
results[index].Result = prevResult;
|
||||
updateResultFlag(index);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveAll() {
|
||||
// Filter to only entries with values that haven't been saved yet
|
||||
const entriesToSave = results.filter(r => r.Result && r.Result.trim() !== '' && !r.saved);
|
||||
@ -100,7 +195,8 @@
|
||||
SampleType: entry.SampleType || null,
|
||||
WorkstationID: entry.WorkstationID ? parseInt(entry.WorkstationID) : null,
|
||||
EquipmentID: entry.EquipmentID ? parseInt(entry.EquipmentID) : null,
|
||||
RefNumID: entry.RefNumID || null
|
||||
RefNumID: entry.RefNumID || null,
|
||||
Comment: entry.Comment || null
|
||||
};
|
||||
|
||||
const response = await updateResult(entry.ResultID, data);
|
||||
@ -143,7 +239,7 @@
|
||||
|
||||
function handleKeyDown(event, index) {
|
||||
// Navigate with Enter/Arrow keys
|
||||
if (event.key === 'Enter') {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
const nextIndex = index + 1;
|
||||
if (nextIndex < results.length) {
|
||||
@ -152,6 +248,23 @@
|
||||
nextInput?.select();
|
||||
}
|
||||
}
|
||||
|
||||
// Shift+Enter: Previous field
|
||||
if (event.key === 'Enter' && event.shiftKey) {
|
||||
event.preventDefault();
|
||||
const prevIndex = index - 1;
|
||||
if (prevIndex >= 0) {
|
||||
const prevInput = document.getElementById(`result-input-${prevIndex}`);
|
||||
prevInput?.focus();
|
||||
prevInput?.select();
|
||||
}
|
||||
}
|
||||
|
||||
// Alt+C: Copy previous result
|
||||
if (event.key === 'c' && event.altKey) {
|
||||
event.preventDefault();
|
||||
copyPreviousResult(index);
|
||||
}
|
||||
}
|
||||
|
||||
const pendingCount = $derived(results.filter(r => !r.Result || r.Result === '').length);
|
||||
@ -186,28 +299,83 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Specimen Information -->
|
||||
{#if order?.Specimens?.length > 0}
|
||||
<div class="card bg-base-100 border border-base-200">
|
||||
<div class="card-body p-3">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<TestTube class="w-4 h-4 text-primary" />
|
||||
<span class="text-sm font-semibold">Specimen Information</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-3 text-xs">
|
||||
{#each order.Specimens as specimen}
|
||||
<div class="bg-base-200/50 rounded p-2">
|
||||
<div class="flex items-center gap-1 text-base-content/60 mb-1">
|
||||
<Hash class="w-3 h-3" />
|
||||
<span>Barcode</span>
|
||||
</div>
|
||||
<div class="font-mono font-medium">{specimen.Barcode || '-'}</div>
|
||||
</div>
|
||||
<div class="bg-base-200/50 rounded p-2">
|
||||
<div class="flex items-center gap-1 text-base-content/60 mb-1">
|
||||
<FlaskConical class="w-3 h-3" />
|
||||
<span>Type</span>
|
||||
</div>
|
||||
<div class="font-medium">{specimen.SpecimenType || '-'}</div>
|
||||
</div>
|
||||
<div class="bg-base-200/50 rounded p-2">
|
||||
<div class="flex items-center gap-1 text-base-content/60 mb-1">
|
||||
<Calendar class="w-3 h-3" />
|
||||
<span>Collected</span>
|
||||
</div>
|
||||
<div class="font-medium">{specimen.CollectionDateTime ? new Date(specimen.CollectionDateTime).toLocaleString() : '-'}</div>
|
||||
</div>
|
||||
<div class="bg-base-200/50 rounded p-2">
|
||||
<div class="flex items-center gap-1 text-base-content/60 mb-1">
|
||||
<User class="w-3 h-3" />
|
||||
<span>Collector</span>
|
||||
</div>
|
||||
<div class="font-medium">{specimen.Collector || '-'}</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Results Table -->
|
||||
<div class="overflow-x-auto max-h-[400px] overflow-y-auto">
|
||||
<table class="table table-sm">
|
||||
<thead class="sticky top-0 bg-base-100 z-10">
|
||||
<tr class="bg-base-200">
|
||||
<th class="text-xs font-semibold w-16">Code</th>
|
||||
<th class="text-xs font-semibold w-12">Code</th>
|
||||
<th class="text-xs font-semibold">Test Name</th>
|
||||
<th class="text-xs font-semibold w-32">Result</th>
|
||||
<th class="text-xs font-semibold w-16">Flag</th>
|
||||
<th class="text-xs font-semibold w-24">Reference</th>
|
||||
<th class="text-xs font-semibold w-20">Unit</th>
|
||||
<th class="text-xs font-semibold w-28">Result</th>
|
||||
<th class="text-xs font-semibold w-12">Flag</th>
|
||||
<th class="text-xs font-semibold w-20">Reference</th>
|
||||
<th class="text-xs font-semibold w-14">Unit</th>
|
||||
<th class="text-xs font-semibold w-16">Status</th>
|
||||
<th class="text-xs font-semibold w-10 text-center">Cmt</th>
|
||||
<th class="text-xs font-semibold w-10"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each results as result, index (result.ResultID)}
|
||||
{@const statusBadge = getResultStatusBadge(result.ResultStatus)}
|
||||
<tr class="hover:bg-base-200/30 {result.saved ? 'opacity-60' : ''}">
|
||||
<td class="text-xs font-mono">{result.TestSiteCode}</td>
|
||||
<td class="text-xs">
|
||||
{result.TestSiteName}
|
||||
{#if result.error}
|
||||
<div class="text-error text-xs">{result.error}</div>
|
||||
{/if}
|
||||
<div class="flex flex-col">
|
||||
<span>{result.TestSiteName}</span>
|
||||
{#if result.error}
|
||||
<div class="text-error text-xs">{result.error}</div>
|
||||
{/if}
|
||||
{#if result.EnteredBy}
|
||||
<div class="text-xs text-base-content/40">
|
||||
By {result.EnteredBy} at {formatDateTime(result.EnteredDateTime)}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
<td class="p-1">
|
||||
<label class="input input-sm input-bordered flex items-center gap-1 w-full {getInputBg(result.flag)}">
|
||||
@ -244,14 +412,66 @@
|
||||
</td>
|
||||
<td class="text-xs text-base-content/60">{result.RefDisplay || '-'}</td>
|
||||
<td class="text-xs">{result.Unit1 || '-'}</td>
|
||||
<td class="text-xs">
|
||||
<span class="badge {statusBadge.color} badge-sm">{statusBadge.label}</span>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<button
|
||||
class="btn btn-ghost btn-xs {result.Comment ? 'text-primary' : 'text-base-content/30'}"
|
||||
onclick={() => toggleCommentRow(index)}
|
||||
title="{result.Comment || 'Add comment'}"
|
||||
>
|
||||
<MessageSquare class="w-3 h-3" />
|
||||
</button>
|
||||
</td>
|
||||
<td class="text-center">
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
class="btn btn-ghost btn-xs text-base-content/30 hover:text-primary"
|
||||
onclick={() => copyPreviousResult(index)}
|
||||
disabled={index === 0}
|
||||
title="Copy previous result (Alt+C)"
|
||||
>
|
||||
<Copy class="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Comment Row -->
|
||||
{#if expandedRows.has(index)}
|
||||
<tr class="bg-base-200/30">
|
||||
<td colspan="9" class="p-2">
|
||||
<div class="flex items-start gap-2">
|
||||
<MessageSquare class="w-4 h-4 text-primary mt-1 flex-shrink-0" />
|
||||
<div class="flex-1">
|
||||
<label class="form-control">
|
||||
<span class="label-text text-xs text-base-content/60">Test Comment</span>
|
||||
<textarea
|
||||
class="textarea textarea-sm textarea-bordered w-full text-xs"
|
||||
placeholder="Enter comment for this test result..."
|
||||
bind:value={result.Comment}
|
||||
disabled={result.saved || formLoading}
|
||||
rows="2"
|
||||
></textarea>
|
||||
</label>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-ghost btn-xs self-end"
|
||||
onclick={() => toggleCommentRow(index)}
|
||||
>
|
||||
<ChevronUp class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/if}
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Legend -->
|
||||
<div class="flex gap-4 text-xs text-base-content/60 pt-2 border-t">
|
||||
<div class="flex flex-wrap gap-4 text-xs text-base-content/60 pt-2 border-t">
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="badge badge-error badge-sm">H</span>
|
||||
<span>High</span>
|
||||
@ -264,8 +484,17 @@
|
||||
<CheckCircle2 class="w-3 h-3 text-success" />
|
||||
<span>Normal</span>
|
||||
</div>
|
||||
<div class="flex-1 text-right">
|
||||
<span class="text-base-content/40">Press Enter to move to next field</span>
|
||||
<div class="flex items-center gap-2 ml-auto">
|
||||
<span class="kbd kbd-sm">Enter</span>
|
||||
<span>Next</span>
|
||||
<span class="kbd kbd-sm">Shift+Enter</span>
|
||||
<span>Prev</span>
|
||||
<span class="kbd kbd-sm">Alt+C</span>
|
||||
<span>Copy</span>
|
||||
<span class="kbd kbd-sm">Ctrl+S</span>
|
||||
<span>Save</span>
|
||||
<span class="kbd kbd-sm">Esc</span>
|
||||
<span>Close</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
426
src/routes/(app)/specimens/+page.svelte
Normal file
426
src/routes/(app)/specimens/+page.svelte
Normal file
@ -0,0 +1,426 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { Plus, Trash2, RefreshCw, Search, Beaker } from 'lucide-svelte';
|
||||
import {
|
||||
fetchSpecimens,
|
||||
fetchSpecimen,
|
||||
createSpecimen,
|
||||
updateSpecimen,
|
||||
deleteSpecimen
|
||||
} from '$lib/api/specimens.js';
|
||||
import { success as toastSuccess, error as toastError } from '$lib/utils/toast.js';
|
||||
import DataTable from '$lib/components/DataTable.svelte';
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
import SpecimenFormModal from './SpecimenFormModal.svelte';
|
||||
import SpecimenDetailModal from './SpecimenDetailModal.svelte';
|
||||
|
||||
// Search state
|
||||
let searchFilters = $state({
|
||||
specimenId: '',
|
||||
specimenType: '',
|
||||
status: ''
|
||||
});
|
||||
|
||||
// List state
|
||||
let loading = $state(false);
|
||||
let specimens = $state([]);
|
||||
let currentPage = $state(1);
|
||||
let perPage = $state(20);
|
||||
let totalItems = $state(0);
|
||||
let totalPages = $state(1);
|
||||
|
||||
// Modal states
|
||||
let specimenForm = $state({
|
||||
open: false,
|
||||
specimen: null,
|
||||
loading: false
|
||||
});
|
||||
|
||||
let specimenDetail = $state({
|
||||
open: false,
|
||||
specimen: null,
|
||||
loading: false
|
||||
});
|
||||
|
||||
let deleteModal = $state({
|
||||
open: false,
|
||||
specimen: null
|
||||
});
|
||||
|
||||
// Load specimens on mount
|
||||
onMount(() => {
|
||||
loadSpecimens();
|
||||
});
|
||||
|
||||
async function loadSpecimens() {
|
||||
loading = true;
|
||||
|
||||
try {
|
||||
const params = {
|
||||
page: currentPage,
|
||||
perPage
|
||||
};
|
||||
|
||||
// Add filters
|
||||
if (searchFilters.specimenId.trim()) {
|
||||
params.SpecimenID = searchFilters.specimenId.trim();
|
||||
}
|
||||
if (searchFilters.specimenType.trim()) {
|
||||
params.SpecimenType = searchFilters.specimenType.trim();
|
||||
}
|
||||
if (searchFilters.status) {
|
||||
params.Status = searchFilters.status;
|
||||
}
|
||||
|
||||
const response = await fetchSpecimens(params);
|
||||
specimens = Array.isArray(response.data) ? response.data : [];
|
||||
|
||||
if (response.pagination) {
|
||||
totalItems = response.pagination.total || 0;
|
||||
totalPages = Math.ceil(totalItems / perPage) || 1;
|
||||
} else {
|
||||
totalItems = specimens.length;
|
||||
totalPages = 1;
|
||||
}
|
||||
} catch (err) {
|
||||
toastError(err.message || 'Failed to load specimens');
|
||||
specimens = [];
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSearch() {
|
||||
currentPage = 1;
|
||||
await loadSpecimens();
|
||||
}
|
||||
|
||||
function handleClear() {
|
||||
searchFilters = {
|
||||
specimenId: '',
|
||||
specimenType: '',
|
||||
status: ''
|
||||
};
|
||||
currentPage = 1;
|
||||
loadSpecimens();
|
||||
}
|
||||
|
||||
function handlePageChange(newPage) {
|
||||
if (newPage >= 1 && newPage <= totalPages) {
|
||||
currentPage = newPage;
|
||||
loadSpecimens();
|
||||
}
|
||||
}
|
||||
|
||||
// CRUD Operations
|
||||
function openCreateModal() {
|
||||
specimenForm = { open: true, specimen: null, loading: false };
|
||||
}
|
||||
|
||||
async function openEditModal(specimen) {
|
||||
specimenForm = { open: true, specimen: null, loading: true };
|
||||
try {
|
||||
const response = await fetchSpecimen(specimen.SpecimenID);
|
||||
specimenForm = {
|
||||
...specimenForm,
|
||||
specimen: response.data || specimen,
|
||||
loading: false
|
||||
};
|
||||
} catch (err) {
|
||||
toastError(err.message || 'Failed to load specimen details');
|
||||
specimenForm = {
|
||||
...specimenForm,
|
||||
specimen: specimen,
|
||||
loading: false
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function openViewModal(specimen) {
|
||||
specimenDetail = { open: true, specimen: null, loading: true };
|
||||
try {
|
||||
const response = await fetchSpecimen(specimen.SpecimenID);
|
||||
specimenDetail = {
|
||||
...specimenDetail,
|
||||
specimen: response.data || specimen,
|
||||
loading: false
|
||||
};
|
||||
} catch (err) {
|
||||
toastError(err.message || 'Failed to load specimen details');
|
||||
specimenDetail = {
|
||||
...specimenDetail,
|
||||
specimen: specimen,
|
||||
loading: false
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveSpecimen(formData) {
|
||||
specimenForm.loading = true;
|
||||
|
||||
try {
|
||||
if (specimenForm.specimen) {
|
||||
// Update existing specimen
|
||||
await updateSpecimen(specimenForm.specimen.SpecimenID, formData);
|
||||
toastSuccess('Specimen updated successfully');
|
||||
} else {
|
||||
// Create new specimen
|
||||
await createSpecimen(formData);
|
||||
toastSuccess('Specimen created successfully');
|
||||
}
|
||||
|
||||
specimenForm = { open: false, specimen: null, loading: false };
|
||||
await loadSpecimens();
|
||||
} catch (err) {
|
||||
toastError(err.message || 'Failed to save specimen');
|
||||
specimenForm.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(specimen) {
|
||||
deleteModal = { open: true, specimen };
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!deleteModal.specimen?.SpecimenID) return;
|
||||
|
||||
try {
|
||||
await deleteSpecimen(deleteModal.specimen.SpecimenID);
|
||||
toastSuccess('Specimen deleted successfully');
|
||||
deleteModal = { open: false, specimen: null };
|
||||
await loadSpecimens();
|
||||
} catch (err) {
|
||||
toastError(err.message || 'Failed to delete specimen');
|
||||
}
|
||||
}
|
||||
|
||||
function handleRefresh() {
|
||||
loadSpecimens();
|
||||
}
|
||||
|
||||
const columns = [
|
||||
{ key: 'SpecimenID', label: 'Specimen ID', class: 'w-32' },
|
||||
{ key: 'SpecimenType', label: 'Type', class: 'w-40' },
|
||||
{ key: 'Status', label: 'Status', class: 'w-24' },
|
||||
{ key: 'CollectionDate', label: 'Collection Date', class: 'w-40' },
|
||||
{ key: 'OrderID', label: 'Order ID', class: 'w-32' },
|
||||
{ key: 'actions', label: 'Actions', class: 'w-32 text-center' }
|
||||
];
|
||||
|
||||
function formatDate(dateString) {
|
||||
if (!dateString) return '-';
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="h-[calc(100vh-4rem)] flex flex-col p-4 gap-3">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<Beaker class="w-8 h-8 text-primary" />
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-800">Specimens</h1>
|
||||
<p class="text-sm text-gray-600">Manage laboratory specimens</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class="btn btn-ghost btn-sm"
|
||||
onclick={handleRefresh}
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshCw class="w-4 h-4 mr-1" />
|
||||
Refresh
|
||||
</button>
|
||||
<button class="btn btn-primary btn-sm" onclick={openCreateModal}>
|
||||
<Plus class="w-4 h-4 mr-1" />
|
||||
New Specimen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Search Bar -->
|
||||
<div class="bg-base-100 p-4 rounded-lg shadow-sm border border-base-200">
|
||||
<div class="flex flex-wrap gap-3 items-end">
|
||||
<label class="form-control flex-1 min-w-[200px]">
|
||||
<span class="label-text text-xs text-gray-500 mb-1">Specimen ID</span>
|
||||
<label class="input input-sm input-bordered flex items-center gap-2 w-full">
|
||||
<Search class="w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
class="grow bg-transparent outline-none"
|
||||
placeholder="Search by ID..."
|
||||
bind:value={searchFilters.specimenId}
|
||||
onkeydown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
/>
|
||||
</label>
|
||||
</label>
|
||||
|
||||
<label class="form-control flex-1 min-w-[200px]">
|
||||
<span class="label-text text-xs text-gray-500 mb-1">Specimen Type</span>
|
||||
<label class="input input-sm input-bordered flex items-center gap-2 w-full">
|
||||
<Beaker class="w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
class="grow bg-transparent outline-none"
|
||||
placeholder="Filter by type..."
|
||||
bind:value={searchFilters.specimenType}
|
||||
onkeydown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
/>
|
||||
</label>
|
||||
</label>
|
||||
|
||||
<label class="form-control w-40">
|
||||
<span class="label-text text-xs text-gray-500 mb-1">Status</span>
|
||||
<select class="select select-sm select-bordered w-full" bind:value={searchFilters.status}>
|
||||
<option value="">All Status</option>
|
||||
<option value="COLLECTED">Collected</option>
|
||||
<option value="RECEIVED">Received</option>
|
||||
<option value="PROCESSING">Processing</option>
|
||||
<option value="COMPLETED">Completed</option>
|
||||
<option value="REJECTED">Rejected</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button class="btn btn-primary btn-sm" onclick={handleSearch}>
|
||||
<Search class="w-4 h-4 mr-1" />
|
||||
Search
|
||||
</button>
|
||||
<button class="btn btn-ghost btn-sm" onclick={handleClear}>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Specimen List -->
|
||||
<div class="flex-1 bg-base-100 rounded-lg shadow border border-base-200 overflow-hidden">
|
||||
<DataTable
|
||||
{columns}
|
||||
data={specimens}
|
||||
{loading}
|
||||
emptyMessage="No specimens found"
|
||||
hover={true}
|
||||
bordered={false}
|
||||
>
|
||||
{#snippet cell({ column, row, value })}
|
||||
{#if column.key === 'Status'}
|
||||
{@const statusColors = {
|
||||
'COLLECTED': 'badge-info',
|
||||
'RECEIVED': 'badge-primary',
|
||||
'PROCESSING': 'badge-warning',
|
||||
'COMPLETED': 'badge-success',
|
||||
'REJECTED': 'badge-error'
|
||||
}}
|
||||
<span class="badge badge-sm {statusColors[value] || 'badge-ghost'}">
|
||||
{value || 'Unknown'}
|
||||
</span>
|
||||
{:else if column.key === 'CollectionDate'}
|
||||
{formatDate(value)}
|
||||
{:else if column.key === 'actions'}
|
||||
<div class="flex justify-center gap-1">
|
||||
<button
|
||||
class="btn btn-xs btn-ghost"
|
||||
onclick={() => openViewModal(row)}
|
||||
title="View specimen details"
|
||||
>
|
||||
View
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-xs btn-ghost"
|
||||
onclick={() => openEditModal(row)}
|
||||
title="Edit specimen"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-xs btn-ghost text-error"
|
||||
onclick={() => confirmDelete(row)}
|
||||
title="Delete specimen"
|
||||
>
|
||||
<Trash2 class="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
{value || '-'}
|
||||
{/if}
|
||||
{/snippet}
|
||||
</DataTable>
|
||||
|
||||
<!-- Pagination -->
|
||||
{#if totalPages > 1}
|
||||
<div class="border-t border-base-200 p-3 flex justify-between items-center">
|
||||
<span class="text-sm text-gray-500">
|
||||
Showing {(currentPage - 1) * perPage + 1} - {Math.min(currentPage * perPage, totalItems)} of {totalItems}
|
||||
</span>
|
||||
<div class="join">
|
||||
<button
|
||||
class="join-item btn btn-sm"
|
||||
onclick={() => handlePageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
«
|
||||
</button>
|
||||
<button class="join-item btn btn-sm">Page {currentPage} of {totalPages}</button>
|
||||
<button
|
||||
class="join-item btn btn-sm"
|
||||
onclick={() => handlePageChange(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
>
|
||||
»
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Specimen Form Modal -->
|
||||
<SpecimenFormModal
|
||||
bind:open={specimenForm.open}
|
||||
specimen={specimenForm.specimen}
|
||||
loading={specimenForm.loading}
|
||||
onSave={handleSaveSpecimen}
|
||||
onCancel={() => specimenForm = { open: false, specimen: null, loading: false }}
|
||||
/>
|
||||
|
||||
<!-- Specimen Detail Modal -->
|
||||
<SpecimenDetailModal
|
||||
bind:open={specimenDetail.open}
|
||||
specimen={specimenDetail.specimen}
|
||||
loading={specimenDetail.loading}
|
||||
onClose={() => specimenDetail = { open: false, specimen: null, loading: false }}
|
||||
/>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<Modal bind:open={deleteModal.open} title="Confirm Delete" size="sm">
|
||||
<div class="py-2">
|
||||
<p>
|
||||
Delete specimen
|
||||
<strong>{deleteModal.specimen?.SpecimenID}</strong>?
|
||||
</p>
|
||||
<p class="text-sm text-error mt-2">This action cannot be undone.</p>
|
||||
</div>
|
||||
{#snippet footer()}
|
||||
<button
|
||||
class="btn btn-ghost btn-sm"
|
||||
onclick={() => deleteModal.open = false}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-error btn-sm"
|
||||
onclick={handleDelete}
|
||||
>
|
||||
<Trash2 class="w-4 h-4 mr-1" />
|
||||
Delete
|
||||
</button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
187
src/routes/(app)/specimens/SpecimenDetailModal.svelte
Normal file
187
src/routes/(app)/specimens/SpecimenDetailModal.svelte
Normal file
@ -0,0 +1,187 @@
|
||||
<script>
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
import { Beaker, Hash, Calendar, User, FileText, FlaskConical, ClipboardList, Info } from 'lucide-svelte';
|
||||
|
||||
/** @type {{
|
||||
* open: boolean,
|
||||
* specimen: Object | null,
|
||||
* loading: boolean,
|
||||
* onClose: () => void
|
||||
* }} */
|
||||
let {
|
||||
open = $bindable(false),
|
||||
specimen = null,
|
||||
loading = false,
|
||||
onClose
|
||||
} = $props();
|
||||
|
||||
function formatDate(dateString) {
|
||||
if (!dateString) return '-';
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
function formatShortDate(dateString) {
|
||||
if (!dateString) return '-';
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
const statusColors = {
|
||||
'COLLECTED': 'badge-info',
|
||||
'RECEIVED': 'badge-primary',
|
||||
'PROCESSING': 'badge-warning',
|
||||
'COMPLETED': 'badge-success',
|
||||
'REJECTED': 'badge-error'
|
||||
};
|
||||
</script>
|
||||
|
||||
<Modal
|
||||
bind:open
|
||||
title="Specimen Details"
|
||||
size="lg"
|
||||
onClose={onClose}
|
||||
>
|
||||
{#if loading}
|
||||
<div class="flex items-center justify-center py-12">
|
||||
<span class="loading loading-spinner loading-lg text-primary"></span>
|
||||
</div>
|
||||
{:else if specimen}
|
||||
<div class="space-y-4">
|
||||
<!-- Specimen Header -->
|
||||
<div class="flex items-start justify-between p-4 bg-base-200 rounded-lg">
|
||||
<div>
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<Beaker class="w-5 h-5 text-gray-500" />
|
||||
<span class="text-lg font-bold">{specimen.SpecimenID}</span>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600">
|
||||
Created: {formatDate(specimen.CreatedDate || specimen.CollectionDate)}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
{#if specimen.Status}
|
||||
<span class="badge badge-md {statusColors[specimen.Status] || 'badge-ghost'}">
|
||||
{specimen.Status}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Specimen Info -->
|
||||
<div class="border border-base-300 rounded-lg p-4">
|
||||
<h4 class="font-semibold text-sm mb-3 flex items-center gap-2">
|
||||
<ClipboardList class="w-4 h-4" />
|
||||
Specimen Information
|
||||
</h4>
|
||||
<div class="grid grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<span class="text-gray-500">Specimen ID:</span>
|
||||
<span class="ml-1 font-medium">{specimen.SpecimenID || '-'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500">Type:</span>
|
||||
<span class="ml-1 font-medium">{specimen.SpecimenType || '-'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500">Status:</span>
|
||||
<span class="ml-1 font-medium">{specimen.Status || '-'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500">Order ID:</span>
|
||||
<span class="ml-1 font-medium">{specimen.OrderID || '-'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Collection Details -->
|
||||
<div class="border border-base-300 rounded-lg p-4">
|
||||
<h4 class="font-semibold text-sm mb-3 flex items-center gap-2">
|
||||
<Calendar class="w-4 h-4" />
|
||||
Collection Details
|
||||
</h4>
|
||||
<div class="grid grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<span class="text-gray-500">Collection Date:</span>
|
||||
<span class="ml-1 font-medium">{formatDate(specimen.CollectionDate)}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500">Collected By:</span>
|
||||
<span class="ml-1 font-medium">{specimen.CollectedBy || '-'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500">Container Type:</span>
|
||||
<span class="ml-1 font-medium">{specimen.ContainerType || '-'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500">Volume:</span>
|
||||
<span class="ml-1 font-medium">
|
||||
{specimen.Volume ? `${specimen.Volume} ${specimen.Units || ''}` : '-'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Comment -->
|
||||
{#if specimen.Comment}
|
||||
<div class="border border-base-300 rounded-lg p-4">
|
||||
<h4 class="font-semibold text-sm mb-3 flex items-center gap-2">
|
||||
<Info class="w-4 h-4" />
|
||||
Comments
|
||||
</h4>
|
||||
<p class="text-sm">{specimen.Comment}</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Related Order (if available) -->
|
||||
{#if specimen.Order}
|
||||
<div class="border border-base-300 rounded-lg p-4">
|
||||
<h4 class="font-semibold text-sm mb-3 flex items-center gap-2">
|
||||
<FileText class="w-4 h-4" />
|
||||
Related Order
|
||||
</h4>
|
||||
<div class="grid grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<span class="text-gray-500">Order ID:</span>
|
||||
<span class="ml-1 font-medium">{specimen.Order.OrderID || '-'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-gray-500">Order Date:</span>
|
||||
<span class="ml-1 font-medium">{formatDate(specimen.Order.OrderDate)}</span>
|
||||
</div>
|
||||
{#if specimen.Order.PatientName}
|
||||
<div class="col-span-2">
|
||||
<span class="text-gray-500">Patient:</span>
|
||||
<span class="ml-1 font-medium">{specimen.Order.PatientName}</span>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-center py-8 text-gray-500">
|
||||
<Beaker class="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||
<p>No specimen data available</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#snippet footer()}
|
||||
<button
|
||||
class="btn btn-ghost btn-sm"
|
||||
onclick={onClose}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
324
src/routes/(app)/specimens/SpecimenFormModal.svelte
Normal file
324
src/routes/(app)/specimens/SpecimenFormModal.svelte
Normal file
@ -0,0 +1,324 @@
|
||||
<script>
|
||||
import Modal from '$lib/components/Modal.svelte';
|
||||
import { Beaker, Hash, Calendar, User, FileText, FlaskConical } from 'lucide-svelte';
|
||||
import { onMount } from 'svelte';
|
||||
import { fetchSpecimenTypes } from '$lib/api/specimens.js';
|
||||
|
||||
/** @type {{
|
||||
* open: boolean,
|
||||
* specimen: Object | null,
|
||||
* loading: boolean,
|
||||
* onSave: (data: Object) => void,
|
||||
* onCancel: () => void
|
||||
* }} */
|
||||
let {
|
||||
open = $bindable(false),
|
||||
specimen = null,
|
||||
loading = false,
|
||||
onSave,
|
||||
onCancel
|
||||
} = $props();
|
||||
|
||||
// Form state
|
||||
let formData = $state({
|
||||
SpecimenID: '',
|
||||
SpecimenType: '',
|
||||
OrderID: '',
|
||||
Status: 'COLLECTED',
|
||||
CollectionDate: '',
|
||||
CollectedBy: '',
|
||||
ContainerType: '',
|
||||
Volume: '',
|
||||
Units: '',
|
||||
Comment: ''
|
||||
});
|
||||
|
||||
let formLoading = $state(false);
|
||||
let formError = $state('');
|
||||
let specimenTypes = $state([]);
|
||||
let typesLoading = $state(false);
|
||||
|
||||
const statusOptions = [
|
||||
{ value: 'COLLECTED', label: 'Collected' },
|
||||
{ value: 'RECEIVED', label: 'Received' },
|
||||
{ value: 'PROCESSING', label: 'Processing' },
|
||||
{ value: 'COMPLETED', label: 'Completed' },
|
||||
{ value: 'REJECTED', label: 'Rejected' }
|
||||
];
|
||||
|
||||
// Load specimen types on mount
|
||||
onMount(async () => {
|
||||
await loadSpecimenTypes();
|
||||
});
|
||||
|
||||
async function loadSpecimenTypes() {
|
||||
typesLoading = true;
|
||||
try {
|
||||
const response = await fetchSpecimenTypes();
|
||||
specimenTypes = Array.isArray(response.data) ? response.data : [];
|
||||
} catch (err) {
|
||||
console.error('Failed to load specimen types:', err);
|
||||
specimenTypes = [];
|
||||
} finally {
|
||||
typesLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Reset form when modal opens
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
if (specimen) {
|
||||
// Edit mode - populate form
|
||||
formData = {
|
||||
SpecimenID: specimen.SpecimenID || '',
|
||||
SpecimenType: specimen.SpecimenType || '',
|
||||
OrderID: specimen.OrderID || '',
|
||||
Status: specimen.Status || 'COLLECTED',
|
||||
CollectionDate: specimen.CollectionDate
|
||||
? new Date(specimen.CollectionDate).toISOString().slice(0, 16)
|
||||
: '',
|
||||
CollectedBy: specimen.CollectedBy || '',
|
||||
ContainerType: specimen.ContainerType || '',
|
||||
Volume: specimen.Volume || '',
|
||||
Units: specimen.Units || '',
|
||||
Comment: specimen.Comment || ''
|
||||
};
|
||||
} else {
|
||||
// Create mode - reset form
|
||||
formData = {
|
||||
SpecimenID: '',
|
||||
SpecimenType: '',
|
||||
OrderID: '',
|
||||
Status: 'COLLECTED',
|
||||
CollectionDate: new Date().toISOString().slice(0, 16),
|
||||
CollectedBy: '',
|
||||
ContainerType: '',
|
||||
Volume: '',
|
||||
Units: '',
|
||||
Comment: ''
|
||||
};
|
||||
}
|
||||
formError = '';
|
||||
}
|
||||
});
|
||||
|
||||
function validateForm() {
|
||||
formError = '';
|
||||
|
||||
if (!formData.SpecimenID?.trim()) {
|
||||
formError = 'Specimen ID is required';
|
||||
return false;
|
||||
}
|
||||
if (!formData.SpecimenType?.trim()) {
|
||||
formError = 'Specimen type is required';
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
if (!validateForm()) return;
|
||||
|
||||
formLoading = true;
|
||||
|
||||
// Prepare data for submission
|
||||
const submitData = {
|
||||
...formData,
|
||||
CollectionDate: formData.CollectionDate
|
||||
? new Date(formData.CollectionDate).toISOString()
|
||||
: null
|
||||
};
|
||||
|
||||
onSave?.(submitData);
|
||||
formLoading = false;
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
formError = '';
|
||||
onCancel?.();
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal
|
||||
bind:open
|
||||
title={specimen ? 'Edit Specimen' : 'New Specimen'}
|
||||
size="lg"
|
||||
onClose={handleClose}
|
||||
>
|
||||
<div class="space-y-4">
|
||||
{#if formError}
|
||||
<div class="alert alert-error alert-sm">
|
||||
<span>{formError}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<!-- Specimen ID -->
|
||||
<label class="form-control">
|
||||
<span class="label-text text-sm">
|
||||
Specimen ID <span class="text-error">*</span>
|
||||
</span>
|
||||
<label class="input input-sm input-bordered flex items-center gap-2 mt-1">
|
||||
<Hash class="w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
class="grow bg-transparent outline-none"
|
||||
placeholder="Enter specimen ID..."
|
||||
bind:value={formData.SpecimenID}
|
||||
disabled={!!specimen}
|
||||
/>
|
||||
</label>
|
||||
</label>
|
||||
|
||||
<!-- Specimen Type -->
|
||||
<label class="form-control">
|
||||
<span class="label-text text-sm">
|
||||
Specimen Type <span class="text-error">*</span>
|
||||
</span>
|
||||
<label class="input input-sm input-bordered flex items-center gap-2 mt-1">
|
||||
<Beaker class="w-4 h-4 text-gray-400" />
|
||||
<select
|
||||
class="grow bg-transparent outline-none"
|
||||
bind:value={formData.SpecimenType}
|
||||
disabled={typesLoading}
|
||||
>
|
||||
<option value="">Select type...</option>
|
||||
{#each specimenTypes as type}
|
||||
<option value={type.SpecimenType}>{type.Description || type.SpecimenType}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
{#if typesLoading}
|
||||
<span class="label-text-alt">Loading types...</span>
|
||||
{/if}
|
||||
</label>
|
||||
|
||||
<!-- Order ID -->
|
||||
<label class="form-control">
|
||||
<span class="label-text text-sm">Order ID</span>
|
||||
<label class="input input-sm input-bordered flex items-center gap-2 mt-1">
|
||||
<FileText class="w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
class="grow bg-transparent outline-none"
|
||||
placeholder="Associated order..."
|
||||
bind:value={formData.OrderID}
|
||||
/>
|
||||
</label>
|
||||
</label>
|
||||
|
||||
<!-- Status -->
|
||||
<label class="form-control">
|
||||
<span class="label-text text-sm">Status</span>
|
||||
<select
|
||||
class="select select-sm select-bordered mt-1 w-full"
|
||||
bind:value={formData.Status}
|
||||
>
|
||||
{#each statusOptions as option}
|
||||
<option value={option.value}>{option.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<!-- Collection Date -->
|
||||
<label class="form-control">
|
||||
<span class="label-text text-sm">Collection Date</span>
|
||||
<label class="input input-sm input-bordered flex items-center gap-2 mt-1">
|
||||
<Calendar class="w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="datetime-local"
|
||||
class="grow bg-transparent outline-none"
|
||||
bind:value={formData.CollectionDate}
|
||||
/>
|
||||
</label>
|
||||
</label>
|
||||
|
||||
<!-- Collected By -->
|
||||
<label class="form-control">
|
||||
<span class="label-text text-sm">Collected By</span>
|
||||
<label class="input input-sm input-bordered flex items-center gap-2 mt-1">
|
||||
<User class="w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
class="grow bg-transparent outline-none"
|
||||
placeholder="Collector name/ID..."
|
||||
bind:value={formData.CollectedBy}
|
||||
/>
|
||||
</label>
|
||||
</label>
|
||||
|
||||
<!-- Container Type -->
|
||||
<label class="form-control">
|
||||
<span class="label-text text-sm">Container Type</span>
|
||||
<label class="input input-sm input-bordered flex items-center gap-2 mt-1">
|
||||
<FlaskConical class="w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
class="grow bg-transparent outline-none"
|
||||
placeholder="Container type..."
|
||||
bind:value={formData.ContainerType}
|
||||
/>
|
||||
</label>
|
||||
</label>
|
||||
|
||||
<!-- Volume -->
|
||||
<label class="form-control">
|
||||
<span class="label-text text-sm">Volume</span>
|
||||
<div class="flex gap-2 mt-1">
|
||||
<label class="input input-sm input-bordered flex items-center gap-2 flex-1">
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
class="grow bg-transparent outline-none"
|
||||
placeholder="Amount..."
|
||||
bind:value={formData.Volume}
|
||||
/>
|
||||
</label>
|
||||
<select
|
||||
class="select select-sm select-bordered w-24"
|
||||
bind:value={formData.Units}
|
||||
>
|
||||
<option value="">Unit</option>
|
||||
<option value="mL">mL</option>
|
||||
<option value="L">L</option>
|
||||
<option value="uL">uL</option>
|
||||
<option value="g">g</option>
|
||||
<option value="mg">mg</option>
|
||||
</select>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Comment -->
|
||||
<label class="form-control">
|
||||
<span class="label-text text-sm">Comment</span>
|
||||
<textarea
|
||||
class="textarea textarea-bordered textarea-sm mt-1 w-full"
|
||||
placeholder="Additional notes..."
|
||||
rows="3"
|
||||
bind:value={formData.Comment}
|
||||
></textarea>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{#snippet footer()}
|
||||
<button
|
||||
class="btn btn-ghost btn-sm"
|
||||
onclick={handleClose}
|
||||
disabled={loading || formLoading}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-primary btn-sm"
|
||||
onclick={handleSubmit}
|
||||
disabled={loading || formLoading}
|
||||
>
|
||||
{#if loading || formLoading}
|
||||
<span class="loading loading-spinner loading-sm mr-2"></span>
|
||||
{/if}
|
||||
{specimen ? 'Update' : 'Create'}
|
||||
</button>
|
||||
{/snippet}
|
||||
</Modal>
|
||||
@ -234,20 +234,19 @@
|
||||
{#if activeTab === 'info'}
|
||||
<div class="space-y-3">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{#if isEdit}
|
||||
<div class="form-control">
|
||||
<label class="label" for="pvid">
|
||||
<span class="label-text font-medium">Visit ID</span>
|
||||
</label>
|
||||
<input
|
||||
id="pvid"
|
||||
type="text"
|
||||
class="input input-sm input-bordered w-full"
|
||||
bind:value={formData.PVID}
|
||||
placeholder="Enter visit ID"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="form-control">
|
||||
<label class="label" for="pvid">
|
||||
<span class="label-text font-medium">Visit ID</span>
|
||||
<span class="label-text-alt text-gray-400">(optional)</span>
|
||||
</label>
|
||||
<input
|
||||
id="pvid"
|
||||
type="text"
|
||||
class="input input-sm input-bordered w-full"
|
||||
bind:value={formData.PVID}
|
||||
placeholder="Enter visit ID (auto-generated if empty)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label" for="patientId">
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user