feat: Enhance contact management and user/group functionalities
- Added ContactCompanyLink model for linking contacts to companies with primary role handling. - Implemented endpoint to link contacts to companies, including conflict resolution for existing links. - Updated auth service to support additional password hashing schemes. - Improved sag creation and update processes with new fields and validation for status. - Enhanced UI for user and group management, including modals for group assignment and permissions. - Introduced new product catalog and improved sales item structure for better billing and aggregation. - Added recursive aggregation logic for financial calculations in cases. - Implemented strict status lifecycle for billing items to prevent double-billing.
This commit is contained in:
parent
56d6d45aa2
commit
b06ff693df
138
SALES_AND_AGGREGATION_PLAN.md
Normal file
138
SALES_AND_AGGREGATION_PLAN.md
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
# Sales and Aggregation Implementation Plan
|
||||||
|
|
||||||
|
## 1. Data Model Proposals
|
||||||
|
|
||||||
|
### 1.1 `sag_salgsvarer` Improvements
|
||||||
|
We will enhance the existing `sag_salgsvarer` table to support full billing requirements, margin calculation, and product linking.
|
||||||
|
|
||||||
|
**Current Fields:**
|
||||||
|
- `id`, `sag_id`, `type` (sale), `description`, `quantity`, `unit`, `unit_price`, `amount`, `currency`, `status`, `line_date`
|
||||||
|
|
||||||
|
**Proposed Additions:**
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| `product_id` | INT (FK) | Link to new `products` catalog (nullable) |
|
||||||
|
| `cost_price` | DECIMAL | For calculating Gross Profit (DB) per line |
|
||||||
|
| `discount_percent` | DECIMAL | Discount given on standard price |
|
||||||
|
| `vat_rate` | DECIMAL | Default 25.00 for DK |
|
||||||
|
| `supplier_id` | INT (FK) | Reference to `vendors` table (if exists) or string |
|
||||||
|
| `billing_method` | VARCHAR | `invoice`, `prepaid`, `internal` (matches `tmodule_times`) |
|
||||||
|
| `is_subscription` | BOOLEAN | If true, pushes to subscription system instead of one-off invoice |
|
||||||
|
|
||||||
|
### 1.2 New `products` Table
|
||||||
|
A central catalog for standard items (Hardware, Licenses, Fees) to speed up entry and standardize reporting.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE products (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
sku VARCHAR(50) UNIQUE,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
category VARCHAR(50), -- 'hardware', 'license', 'consulting'
|
||||||
|
cost_price DECIMAL(10,2),
|
||||||
|
sales_price DECIMAL(10,2), -- Suggested RRP
|
||||||
|
unit VARCHAR(20) DEFAULT 'stk',
|
||||||
|
supplier_id INTEGER,
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.3 Aggregation Rules
|
||||||
|
The system will distinguish between **Direct** costs/revenue (on the case itself) and **Aggregated** (from sub-cases).
|
||||||
|
|
||||||
|
- **Direct Revenue** = (Sum of `sag_salgsvarer.amount`) + (Sum of `tmodule_times` where `billable=true` * `hourly_rate`)
|
||||||
|
- **Total Revenue** = Direct Revenue + Sum(Child Cases Total Revenue)
|
||||||
|
|
||||||
|
## 2. UI Structure for "Varer" (Items) Tab
|
||||||
|
|
||||||
|
The "Varer" tab on the Case Detail page will have a split entry/view design.
|
||||||
|
|
||||||
|
### 2.1 Top Section: Quick Add
|
||||||
|
A horizontal form to quickly add lines:
|
||||||
|
- **Product Lookup**: Searchable dropdown.
|
||||||
|
- **Manual Override**: Description field auto-filled but editable.
|
||||||
|
- **Numbers**: Qty, Unit, Price.
|
||||||
|
- **Result**: Total Price auto-calculated.
|
||||||
|
- **Action**: "Add Line" button.
|
||||||
|
|
||||||
|
### 2.2 Main List: Combined Billing View
|
||||||
|
A unified table showing everything billable on this case:
|
||||||
|
|
||||||
|
| Type | Date | Description | Qty | Price | Disc | Total | Status | Actions |
|
||||||
|
|------|------|-------------|-----|-------|------|-------|--------|---------|
|
||||||
|
| 🕒 Time | 02-02 | Konsulentbistand | 2.5 | 1200 | 0% | 3000 | `Approved` | [Edit Time] |
|
||||||
|
| 📦 Item | 02-02 | Ubiquiti Switch | 1 | 2500 | 10% | 2250 | `Draft` | [Edit] [Del] |
|
||||||
|
| 🔄 Sub | -- | *Sub-case: Installation i Aarhus* | -- | -- | -- | 5400 | `Calculated` | [Go to Case] |
|
||||||
|
|
||||||
|
### 2.3 Summary Footer (Sticky)
|
||||||
|
- **Materials**: Total of Items.
|
||||||
|
- **Labor**: Total of Time.
|
||||||
|
- **Sub-cases**: Total of Children.
|
||||||
|
- **Grand Total**: Ex VAT and Inc VAT.
|
||||||
|
- **Margin**: (Sales - Cost) / Sales %.
|
||||||
|
- **Action**: "Create Invoice Proposal" button.
|
||||||
|
|
||||||
|
## 3. Aggregation Logic (Recursive)
|
||||||
|
|
||||||
|
We will implement a `SalesAggregator` service that traverses the case tree.
|
||||||
|
|
||||||
|
**Algorithm:**
|
||||||
|
1. **Inputs**: `case_id`.
|
||||||
|
2. **Fetch Direct Items**: Query `sag_salgsvarer` for this case.
|
||||||
|
3. **Fetch Direct Time**: Query `tmodule_times` for this case. Calculate value using `hourly_rate`.
|
||||||
|
4. **Fetch Children**: Query `sag_relationer` (or `sag_sager` parent_id) to find children.
|
||||||
|
5. **Recursion**: For each child, recursively call `get_case_totals(child_id)`.
|
||||||
|
6. **Summation**: Return object with `own_total` and `sub_total`.
|
||||||
|
|
||||||
|
**Python Service Method:**
|
||||||
|
```python
|
||||||
|
def get_case_financials(case_id: int) -> CaseFinancials:
|
||||||
|
# 1. Own items
|
||||||
|
items = db.query(SagSalgsvarer).filter(sag_id=case_id).all()
|
||||||
|
item_total = sum(i.amount for i in items)
|
||||||
|
item_cost = sum(i.cost_price * i.quantity for i in items)
|
||||||
|
|
||||||
|
# 2. Own time
|
||||||
|
times = db.query(TmoduleTimes).filter(case_id=case_id, billable=True).all()
|
||||||
|
time_total = sum(t.original_hours * get_hourly_rate(case_id) for t in times)
|
||||||
|
|
||||||
|
# 3. Children
|
||||||
|
children = db.query(SagRelationer).filter(kilde_sag_id=case_id).all()
|
||||||
|
child_total = 0
|
||||||
|
child_cost = 0
|
||||||
|
|
||||||
|
for child in children:
|
||||||
|
child_fin = get_case_financials(child.malsag_id)
|
||||||
|
child_total += child_fin.total_revenue
|
||||||
|
child_cost += child_fin.total_cost
|
||||||
|
|
||||||
|
return CaseFinancials(
|
||||||
|
revenue=item_total + time_total + child_total,
|
||||||
|
cost=item_cost + child_cost,
|
||||||
|
# ... breakdown fields
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Preparation for Billing (Status Flow)
|
||||||
|
|
||||||
|
We define a strict lifecycle for items to prevent double-billing.
|
||||||
|
|
||||||
|
### 4.1 Status Lifecycle for Items (`sag_salgsvarer`)
|
||||||
|
1. **`draft`**: Default. Editable. Included in Preliminary Total.
|
||||||
|
2. **`approved`**: Locked by Project Manager. Ready for Finance.
|
||||||
|
- *Action*: Lock for Billing.
|
||||||
|
- *Effect*: Rows become read-only.
|
||||||
|
3. **`billed`**: Processed by Finance (exported to e-conomic).
|
||||||
|
- *Action*: Integration Job runs.
|
||||||
|
- *Effect*: Linked to `invoice_id` (new column).
|
||||||
|
|
||||||
|
### 4.2 Billing Triggers
|
||||||
|
- **Partial Billing**: Checkbox select specific `approved` lines -> Create Invoice Draft.
|
||||||
|
- **Full Billing**: Bill All Approved -> Generates invoice for all `approved` items and time.
|
||||||
|
- **Aggregation Billing**:
|
||||||
|
- The invoicing engine must accept a `case_structure` to decide if it prints one line per sub-case or expands all lines. Default to **One line per sub-case** for cleanliness.
|
||||||
|
|
||||||
|
### 4.3 Validation
|
||||||
|
- Ensure all Approved items have a valid `cost_price` (warn if 0).
|
||||||
|
- Ensure Time Registrations are `approved` before they can be billed.
|
||||||
@ -2,7 +2,7 @@
|
|||||||
Auth Admin API - Users, Groups, Permissions management
|
Auth Admin API - Users, Groups, Permissions management
|
||||||
"""
|
"""
|
||||||
from fastapi import APIRouter, HTTPException, status, Depends
|
from fastapi import APIRouter, HTTPException, status, Depends
|
||||||
from app.core.auth_dependencies import require_superadmin
|
from app.core.auth_dependencies import require_permission
|
||||||
from app.core.auth_service import AuthService
|
from app.core.auth_service import AuthService
|
||||||
from app.core.database import execute_query, execute_query_single, execute_insert, execute_update
|
from app.core.database import execute_query, execute_query_single, execute_insert, execute_update
|
||||||
from app.models.schemas import UserAdminCreate, UserGroupsUpdate, GroupCreate, GroupPermissionsUpdate
|
from app.models.schemas import UserAdminCreate, UserGroupsUpdate, GroupCreate, GroupPermissionsUpdate
|
||||||
@ -13,12 +13,13 @@ logger = logging.getLogger(__name__)
|
|||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@router.get("/admin/users", dependencies=[Depends(require_superadmin)])
|
@router.get("/admin/users", dependencies=[Depends(require_permission("users.manage"))])
|
||||||
async def list_users():
|
async def list_users():
|
||||||
users = execute_query(
|
users = execute_query(
|
||||||
"""
|
"""
|
||||||
SELECT u.user_id, u.username, u.email, u.full_name,
|
SELECT u.user_id, u.username, u.email, u.full_name,
|
||||||
u.is_active, u.is_superadmin, u.is_2fa_enabled,
|
u.is_active, u.is_superadmin, u.is_2fa_enabled,
|
||||||
|
u.created_at, u.last_login_at,
|
||||||
COALESCE(array_remove(array_agg(g.name), NULL), ARRAY[]::varchar[]) AS groups
|
COALESCE(array_remove(array_agg(g.name), NULL), ARRAY[]::varchar[]) AS groups
|
||||||
FROM users u
|
FROM users u
|
||||||
LEFT JOIN user_groups ug ON u.user_id = ug.user_id
|
LEFT JOIN user_groups ug ON u.user_id = ug.user_id
|
||||||
@ -30,7 +31,7 @@ async def list_users():
|
|||||||
return users
|
return users
|
||||||
|
|
||||||
|
|
||||||
@router.post("/admin/users", status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_superadmin)])
|
@router.post("/admin/users", status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_permission("users.manage"))])
|
||||||
async def create_user(payload: UserAdminCreate):
|
async def create_user(payload: UserAdminCreate):
|
||||||
existing = execute_query_single(
|
existing = execute_query_single(
|
||||||
"SELECT user_id FROM users WHERE username = %s OR email = %s",
|
"SELECT user_id FROM users WHERE username = %s OR email = %s",
|
||||||
@ -42,7 +43,14 @@ async def create_user(payload: UserAdminCreate):
|
|||||||
detail="Username or email already exists"
|
detail="Username or email already exists"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
password_hash = AuthService.hash_password(payload.password)
|
password_hash = AuthService.hash_password(payload.password)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("❌ Password hash failed: %s", exc)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="Kunne ikke hashe adgangskoden"
|
||||||
|
) from exc
|
||||||
user_id = execute_insert(
|
user_id = execute_insert(
|
||||||
"""
|
"""
|
||||||
INSERT INTO users (username, email, password_hash, full_name, is_superadmin, is_active)
|
INSERT INTO users (username, email, password_hash, full_name, is_superadmin, is_active)
|
||||||
@ -53,7 +61,7 @@ async def create_user(payload: UserAdminCreate):
|
|||||||
|
|
||||||
if payload.group_ids:
|
if payload.group_ids:
|
||||||
for group_id in payload.group_ids:
|
for group_id in payload.group_ids:
|
||||||
execute_insert(
|
execute_update(
|
||||||
"""
|
"""
|
||||||
INSERT INTO user_groups (user_id, group_id)
|
INSERT INTO user_groups (user_id, group_id)
|
||||||
VALUES (%s, %s) ON CONFLICT DO NOTHING
|
VALUES (%s, %s) ON CONFLICT DO NOTHING
|
||||||
@ -65,7 +73,7 @@ async def create_user(payload: UserAdminCreate):
|
|||||||
return {"user_id": user_id}
|
return {"user_id": user_id}
|
||||||
|
|
||||||
|
|
||||||
@router.put("/admin/users/{user_id}/groups", dependencies=[Depends(require_superadmin)])
|
@router.put("/admin/users/{user_id}/groups", dependencies=[Depends(require_permission("users.manage"))])
|
||||||
async def update_user_groups(user_id: int, payload: UserGroupsUpdate):
|
async def update_user_groups(user_id: int, payload: UserGroupsUpdate):
|
||||||
user = execute_query_single("SELECT user_id FROM users WHERE user_id = %s", (user_id,))
|
user = execute_query_single("SELECT user_id FROM users WHERE user_id = %s", (user_id,))
|
||||||
if not user:
|
if not user:
|
||||||
@ -74,7 +82,7 @@ async def update_user_groups(user_id: int, payload: UserGroupsUpdate):
|
|||||||
execute_update("DELETE FROM user_groups WHERE user_id = %s", (user_id,))
|
execute_update("DELETE FROM user_groups WHERE user_id = %s", (user_id,))
|
||||||
|
|
||||||
for group_id in payload.group_ids:
|
for group_id in payload.group_ids:
|
||||||
execute_insert(
|
execute_update(
|
||||||
"""
|
"""
|
||||||
INSERT INTO user_groups (user_id, group_id)
|
INSERT INTO user_groups (user_id, group_id)
|
||||||
VALUES (%s, %s) ON CONFLICT DO NOTHING
|
VALUES (%s, %s) ON CONFLICT DO NOTHING
|
||||||
@ -85,7 +93,7 @@ async def update_user_groups(user_id: int, payload: UserGroupsUpdate):
|
|||||||
return {"message": "Groups updated"}
|
return {"message": "Groups updated"}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/admin/groups", dependencies=[Depends(require_superadmin)])
|
@router.get("/admin/groups", dependencies=[Depends(require_permission("users.manage"))])
|
||||||
async def list_groups():
|
async def list_groups():
|
||||||
groups = execute_query(
|
groups = execute_query(
|
||||||
"""
|
"""
|
||||||
@ -101,7 +109,7 @@ async def list_groups():
|
|||||||
return groups
|
return groups
|
||||||
|
|
||||||
|
|
||||||
@router.post("/admin/groups", status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_superadmin)])
|
@router.post("/admin/groups", status_code=status.HTTP_201_CREATED, dependencies=[Depends(require_permission("permissions.manage"))])
|
||||||
async def create_group(payload: GroupCreate):
|
async def create_group(payload: GroupCreate):
|
||||||
existing = execute_query_single("SELECT id FROM groups WHERE name = %s", (payload.name,))
|
existing = execute_query_single("SELECT id FROM groups WHERE name = %s", (payload.name,))
|
||||||
if existing:
|
if existing:
|
||||||
@ -118,7 +126,7 @@ async def create_group(payload: GroupCreate):
|
|||||||
return {"group_id": group_id}
|
return {"group_id": group_id}
|
||||||
|
|
||||||
|
|
||||||
@router.put("/admin/groups/{group_id}/permissions", dependencies=[Depends(require_superadmin)])
|
@router.put("/admin/groups/{group_id}/permissions", dependencies=[Depends(require_permission("permissions.manage"))])
|
||||||
async def update_group_permissions(group_id: int, payload: GroupPermissionsUpdate):
|
async def update_group_permissions(group_id: int, payload: GroupPermissionsUpdate):
|
||||||
group = execute_query_single("SELECT id FROM groups WHERE id = %s", (group_id,))
|
group = execute_query_single("SELECT id FROM groups WHERE id = %s", (group_id,))
|
||||||
if not group:
|
if not group:
|
||||||
@ -127,7 +135,7 @@ async def update_group_permissions(group_id: int, payload: GroupPermissionsUpdat
|
|||||||
execute_update("DELETE FROM group_permissions WHERE group_id = %s", (group_id,))
|
execute_update("DELETE FROM group_permissions WHERE group_id = %s", (group_id,))
|
||||||
|
|
||||||
for permission_id in payload.permission_ids:
|
for permission_id in payload.permission_ids:
|
||||||
execute_insert(
|
execute_update(
|
||||||
"""
|
"""
|
||||||
INSERT INTO group_permissions (group_id, permission_id)
|
INSERT INTO group_permissions (group_id, permission_id)
|
||||||
VALUES (%s, %s) ON CONFLICT DO NOTHING
|
VALUES (%s, %s) ON CONFLICT DO NOTHING
|
||||||
@ -138,7 +146,7 @@ async def update_group_permissions(group_id: int, payload: GroupPermissionsUpdat
|
|||||||
return {"message": "Permissions updated"}
|
return {"message": "Permissions updated"}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/admin/permissions", dependencies=[Depends(require_superadmin)])
|
@router.get("/admin/permissions", dependencies=[Depends(require_permission("permissions.manage"))])
|
||||||
async def list_permissions():
|
async def list_permissions():
|
||||||
permissions = execute_query(
|
permissions = execute_query(
|
||||||
"""
|
"""
|
||||||
|
|||||||
@ -23,6 +23,12 @@ class ContactCreate(BaseModel):
|
|||||||
company_id: Optional[int] = None
|
company_id: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
class ContactCompanyLink(BaseModel):
|
||||||
|
customer_id: int
|
||||||
|
is_primary: bool = True
|
||||||
|
role: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/contacts-debug")
|
@router.get("/contacts-debug")
|
||||||
async def debug_contacts():
|
async def debug_contacts():
|
||||||
@ -167,6 +173,9 @@ async def create_contact(contact: ContactCreate):
|
|||||||
link_query = """
|
link_query = """
|
||||||
INSERT INTO contact_companies (contact_id, customer_id, is_primary, role)
|
INSERT INTO contact_companies (contact_id, customer_id, is_primary, role)
|
||||||
VALUES (%s, %s, true, 'primary')
|
VALUES (%s, %s, true, 'primary')
|
||||||
|
ON CONFLICT (contact_id, customer_id)
|
||||||
|
DO UPDATE SET is_primary = EXCLUDED.is_primary, role = EXCLUDED.role
|
||||||
|
RETURNING id
|
||||||
"""
|
"""
|
||||||
execute_insert(link_query, (contact_id, contact.company_id))
|
execute_insert(link_query, (contact_id, contact.company_id))
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@ -221,3 +230,34 @@ async def get_contact(contact_id: int):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to get contact {contact_id}: {e}")
|
logger.error(f"Failed to get contact {contact_id}: {e}")
|
||||||
raise HTTPException(status_code=500, detail=str(e))
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/contacts/{contact_id}/companies")
|
||||||
|
async def link_contact_to_company(contact_id: int, link: ContactCompanyLink):
|
||||||
|
"""Link a contact to a company"""
|
||||||
|
try:
|
||||||
|
# Ensure contact exists
|
||||||
|
contact = execute_query("SELECT id FROM contacts WHERE id = %s", (contact_id,))
|
||||||
|
if not contact:
|
||||||
|
raise HTTPException(status_code=404, detail="Contact not found")
|
||||||
|
|
||||||
|
# Ensure customer exists
|
||||||
|
customer = execute_query("SELECT id FROM customers WHERE id = %s", (link.customer_id,))
|
||||||
|
if not customer:
|
||||||
|
raise HTTPException(status_code=404, detail="Customer not found")
|
||||||
|
|
||||||
|
query = """
|
||||||
|
INSERT INTO contact_companies (contact_id, customer_id, is_primary, role)
|
||||||
|
VALUES (%s, %s, %s, %s)
|
||||||
|
ON CONFLICT (contact_id, customer_id)
|
||||||
|
DO UPDATE SET is_primary = EXCLUDED.is_primary, role = EXCLUDED.role
|
||||||
|
RETURNING id
|
||||||
|
"""
|
||||||
|
execute_insert(query, (contact_id, link.customer_id, link.is_primary, link.role))
|
||||||
|
|
||||||
|
return {"message": "Contact linked to company successfully"}
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to link contact to company: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|||||||
@ -20,7 +20,7 @@ SECRET_KEY = getattr(settings, 'JWT_SECRET_KEY', 'your-secret-key-change-in-prod
|
|||||||
ALGORITHM = "HS256"
|
ALGORITHM = "HS256"
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 8 # 8 timer
|
ACCESS_TOKEN_EXPIRE_MINUTES = 60 * 8 # 8 timer
|
||||||
|
|
||||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
pwd_context = CryptContext(schemes=["pbkdf2_sha256", "bcrypt_sha256", "bcrypt"], deprecated="auto")
|
||||||
|
|
||||||
|
|
||||||
class AuthService:
|
class AuthService:
|
||||||
@ -38,9 +38,9 @@ class AuthService:
|
|||||||
"""Verify password against hash"""
|
"""Verify password against hash"""
|
||||||
if not hashed_password:
|
if not hashed_password:
|
||||||
return False
|
return False
|
||||||
if not hashed_password.startswith("$2"):
|
|
||||||
return False
|
|
||||||
try:
|
try:
|
||||||
|
if not hashed_password.startswith("$"):
|
||||||
|
return False
|
||||||
return pwd_context.verify(plain_password, hashed_password)
|
return pwd_context.verify(plain_password, hashed_password)
|
||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
@ -235,7 +235,9 @@ class AuthService:
|
|||||||
User dict if successful, None otherwise
|
User dict if successful, None otherwise
|
||||||
"""
|
"""
|
||||||
# Shadow Admin shortcut
|
# Shadow Admin shortcut
|
||||||
if settings.SHADOW_ADMIN_ENABLED and username == settings.SHADOW_ADMIN_USERNAME:
|
shadow_username = (settings.SHADOW_ADMIN_USERNAME or "shadowadmin").strip().lower()
|
||||||
|
request_username = (username or "").strip().lower()
|
||||||
|
if settings.SHADOW_ADMIN_ENABLED and request_username == shadow_username:
|
||||||
if not settings.SHADOW_ADMIN_PASSWORD or not settings.SHADOW_ADMIN_TOTP_SECRET:
|
if not settings.SHADOW_ADMIN_PASSWORD or not settings.SHADOW_ADMIN_TOTP_SECRET:
|
||||||
logger.error("❌ Shadow admin enabled but not configured")
|
logger.error("❌ Shadow admin enabled but not configured")
|
||||||
return None, "Shadow admin not configured"
|
return None, "Shadow admin not configured"
|
||||||
|
|||||||
@ -126,17 +126,21 @@ async def create_sag(data: dict):
|
|||||||
if not data.get("customer_id"):
|
if not data.get("customer_id"):
|
||||||
raise HTTPException(status_code=400, detail="customer_id is required")
|
raise HTTPException(status_code=400, detail="customer_id is required")
|
||||||
|
|
||||||
|
status = data.get("status", "åben")
|
||||||
|
if status not in {"åben", "lukket"}:
|
||||||
|
status = "åben"
|
||||||
|
|
||||||
query = """
|
query = """
|
||||||
INSERT INTO sag_sager
|
INSERT INTO sag_sager
|
||||||
(titel, beskrivelse, type, status, customer_id, ansvarlig_bruger_id, created_by_user_id, deadline)
|
(titel, beskrivelse, template_key, status, customer_id, ansvarlig_bruger_id, created_by_user_id, deadline)
|
||||||
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
||||||
RETURNING *
|
RETURNING *
|
||||||
"""
|
"""
|
||||||
params = (
|
params = (
|
||||||
data.get("titel"),
|
data.get("titel"),
|
||||||
data.get("beskrivelse", ""),
|
data.get("beskrivelse", ""),
|
||||||
data.get("type", "ticket"),
|
data.get("template_key") or data.get("type", "ticket"),
|
||||||
data.get("status", "åben"),
|
status,
|
||||||
data.get("customer_id"),
|
data.get("customer_id"),
|
||||||
data.get("ansvarlig_bruger_id"),
|
data.get("ansvarlig_bruger_id"),
|
||||||
data.get("created_by_user_id", 1),
|
data.get("created_by_user_id", 1),
|
||||||
@ -177,7 +181,7 @@ async def update_sag(sag_id: int, updates: dict):
|
|||||||
raise HTTPException(status_code=404, detail="Case not found")
|
raise HTTPException(status_code=404, detail="Case not found")
|
||||||
|
|
||||||
# Build dynamic update query
|
# Build dynamic update query
|
||||||
allowed_fields = ["titel", "beskrivelse", "type", "status", "ansvarlig_bruger_id", "deadline"]
|
allowed_fields = ["titel", "beskrivelse", "template_key", "status", "ansvarlig_bruger_id", "deadline"]
|
||||||
set_clauses = []
|
set_clauses = []
|
||||||
params = []
|
params = []
|
||||||
|
|
||||||
|
|||||||
@ -330,7 +330,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (data.length === 0) {
|
if (data.length === 0) {
|
||||||
resultsDiv.innerHTML = '<div class="p-3 text-muted small">Ingen fundet</div>';
|
const quickAction = type === 'customer'
|
||||||
|
? `<button class="btn btn-sm btn-outline-primary mt-2" onclick="quickCreateCustomer('${query.replace(/'/g, "\\'")}')">Opret nyt firma</button>`
|
||||||
|
: `<button class="btn btn-sm btn-outline-primary mt-2" onclick="quickCreateContact('${query.replace(/'/g, "\\'")}')">Opret ny kontakt</button>`;
|
||||||
|
resultsDiv.innerHTML = `
|
||||||
|
<div class="p-3 text-muted small">Ingen fundet</div>
|
||||||
|
<div class="p-3 pt-0">${quickAction}</div>
|
||||||
|
`;
|
||||||
} else {
|
} else {
|
||||||
resultsDiv.innerHTML = data.map(item => {
|
resultsDiv.innerHTML = data.map(item => {
|
||||||
const name = type === 'customer' ? item.name : `${item.first_name} ${item.last_name}`;
|
const name = type === 'customer' ? item.name : `${item.first_name} ${item.last_name}`;
|
||||||
@ -432,6 +438,78 @@
|
|||||||
`).join('');
|
`).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function quickCreateCustomer(name) {
|
||||||
|
if (!name || name.trim().length < 2) return;
|
||||||
|
const resultsDiv = document.getElementById('customerResults');
|
||||||
|
resultsDiv.innerHTML = '<div class="p-3 text-muted small"><span class="spinner-border spinner-border-sm me-2"></span>Opretter firma...</div>';
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/v1/customers', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name: name.trim() })
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const err = await response.json();
|
||||||
|
throw new Error(err.detail || 'Kunne ikke oprette firma');
|
||||||
|
}
|
||||||
|
const created = await response.json();
|
||||||
|
selectCustomer(created.id, created.name);
|
||||||
|
|
||||||
|
const contactIds = Object.keys(selectedContacts).map(id => parseInt(id));
|
||||||
|
if (contactIds.length) {
|
||||||
|
const linkResponses = await Promise.all(contactIds.map(contactId =>
|
||||||
|
fetch(`/api/v1/contacts/${contactId}/companies`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ customer_id: created.id, is_primary: false })
|
||||||
|
})
|
||||||
|
));
|
||||||
|
const failed = linkResponses.find(res => !res.ok);
|
||||||
|
if (failed) {
|
||||||
|
const err = await failed.json();
|
||||||
|
throw new Error(err.detail || 'Kunne ikke linke kontakt til firma');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return created;
|
||||||
|
} catch (err) {
|
||||||
|
resultsDiv.innerHTML = `<div class="p-3 text-danger small">${err.message}</div>`;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function quickCreateContact(fullName) {
|
||||||
|
if (!fullName || fullName.trim().length < 2) return;
|
||||||
|
const resultsDiv = document.getElementById('contactResults');
|
||||||
|
resultsDiv.innerHTML = '<div class="p-3 text-muted small"><span class="spinner-border spinner-border-sm me-2"></span>Opretter kontakt...</div>';
|
||||||
|
|
||||||
|
const parts = fullName.trim().split(/\s+/);
|
||||||
|
const firstName = parts.shift() || '';
|
||||||
|
const lastName = parts.join(' ');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
first_name: firstName,
|
||||||
|
last_name: lastName,
|
||||||
|
company_id: selectedCustomer ? selectedCustomer.id : null
|
||||||
|
};
|
||||||
|
const response = await fetch('/api/v1/contacts', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const err = await response.json();
|
||||||
|
throw new Error(err.detail || 'Kunne ikke oprette kontakt');
|
||||||
|
}
|
||||||
|
const created = await response.json();
|
||||||
|
await selectContact(created.id, `${created.first_name} ${created.last_name}`.trim());
|
||||||
|
return created;
|
||||||
|
} catch (err) {
|
||||||
|
resultsDiv.innerHTML = `<div class="p-3 text-danger small">${err.message}</div>`;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadCaseTypesSelect() {
|
async function loadCaseTypesSelect() {
|
||||||
const select = document.getElementById('type');
|
const select = document.getElementById('type');
|
||||||
if (!select) return;
|
if (!select) return;
|
||||||
@ -486,6 +564,34 @@
|
|||||||
const originalBtnText = btn.innerHTML;
|
const originalBtnText = btn.innerHTML;
|
||||||
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Opretter...';
|
btn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Opretter...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const customerQuery = document.getElementById('customerSearch').value.trim();
|
||||||
|
if (!selectedCustomer && customerQuery.length >= 2) {
|
||||||
|
const res = await fetch(`/api/v1/search/customers?q=${encodeURIComponent(customerQuery)}`);
|
||||||
|
const matches = await res.json();
|
||||||
|
if (Array.isArray(matches) && matches.length > 0) {
|
||||||
|
throw new Error('Firma findes allerede. Vælg det fra listen.');
|
||||||
|
}
|
||||||
|
await quickCreateCustomer(customerQuery);
|
||||||
|
}
|
||||||
|
|
||||||
|
const contactQuery = document.getElementById('contactSearch').value.trim();
|
||||||
|
if (Object.keys(selectedContacts).length === 0 && contactQuery.length >= 2) {
|
||||||
|
const res = await fetch(`/api/v1/search/contacts?q=${encodeURIComponent(contactQuery)}`);
|
||||||
|
const matches = await res.json();
|
||||||
|
if (Array.isArray(matches) && matches.length > 0) {
|
||||||
|
throw new Error('Kontakt findes allerede. Vælg den fra listen.');
|
||||||
|
}
|
||||||
|
await quickCreateContact(contactQuery);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
errorDiv.classList.remove('d-none');
|
||||||
|
document.getElementById('error-text').textContent = err.message;
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = originalBtnText;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
titel: titel,
|
titel: titel,
|
||||||
beskrivelse: document.getElementById('beskrivelse').value || '',
|
beskrivelse: document.getElementById('beskrivelse').value || '',
|
||||||
@ -518,6 +624,23 @@
|
|||||||
|
|
||||||
await Promise.all(contactPromises);
|
await Promise.all(contactPromises);
|
||||||
|
|
||||||
|
// Ensure contact-company link exists
|
||||||
|
if (selectedCustomer) {
|
||||||
|
const linkPromises = Object.keys(selectedContacts).map(cid =>
|
||||||
|
fetch(`/api/v1/contacts/${parseInt(cid)}/companies`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({ customer_id: selectedCustomer.id, is_primary: false })
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const linkResponses = await Promise.all(linkPromises);
|
||||||
|
const linkFailed = linkResponses.find(res => !res.ok);
|
||||||
|
if (linkFailed) {
|
||||||
|
const err = await linkFailed.json();
|
||||||
|
throw new Error(err.detail || 'Kunne ikke linke kontakt til firma');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
successDiv.classList.remove('d-none');
|
successDiv.classList.remove('d-none');
|
||||||
document.getElementById('success-text').textContent = "Sag oprettet succesfuldt! Omdirigerer...";
|
document.getElementById('success-text').textContent = "Sag oprettet succesfuldt! Omdirigerer...";
|
||||||
|
|
||||||
|
|||||||
@ -273,11 +273,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Users -->
|
<!-- Users & Groups -->
|
||||||
<div class="tab-pane fade" id="users">
|
<div class="tab-pane fade" id="users">
|
||||||
|
<div class="row g-4">
|
||||||
|
<div class="col-xl-7">
|
||||||
<div class="card p-4">
|
<div class="card p-4">
|
||||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
<h5 class="mb-0 fw-bold">Brugerstyring</h5>
|
<div>
|
||||||
|
<h5 class="mb-1 fw-bold">Brugere</h5>
|
||||||
|
<p class="text-muted mb-0">Opret og administrer brugere og deres grupper</p>
|
||||||
|
</div>
|
||||||
<button class="btn btn-primary" onclick="showCreateUserModal()">
|
<button class="btn btn-primary" onclick="showCreateUserModal()">
|
||||||
<i class="bi bi-plus-lg me-2"></i>Opret Bruger
|
<i class="bi bi-plus-lg me-2"></i>Opret Bruger
|
||||||
</button>
|
</button>
|
||||||
@ -288,8 +293,8 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th>Bruger</th>
|
<th>Bruger</th>
|
||||||
<th>Email</th>
|
<th>Email</th>
|
||||||
|
<th>Grupper</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th>Sidst Login</th>
|
|
||||||
<th>Oprettet</th>
|
<th>Oprettet</th>
|
||||||
<th class="text-end">Handlinger</th>
|
<th class="text-end">Handlinger</th>
|
||||||
</tr>
|
</tr>
|
||||||
@ -305,6 +310,39 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="col-xl-5">
|
||||||
|
<div class="card p-4">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||||
|
<div>
|
||||||
|
<h5 class="mb-1 fw-bold">Grupper</h5>
|
||||||
|
<p class="text-muted mb-0">Opret grupper og tildel rettigheder</p>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-outline-primary" onclick="showCreateGroupModal()">
|
||||||
|
<i class="bi bi-plus-lg me-2"></i>Opret Gruppe
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-hover align-middle">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Gruppe</th>
|
||||||
|
<th>Rettigheder</th>
|
||||||
|
<th class="text-end">Handlinger</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="groupsTableBody">
|
||||||
|
<tr>
|
||||||
|
<td colspan="3" class="text-center py-5">
|
||||||
|
<div class="spinner-border text-primary" role="status"></div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Tags Management -->
|
<!-- Tags Management -->
|
||||||
<div class="tab-pane fade" id="tags">
|
<div class="tab-pane fade" id="tags">
|
||||||
@ -895,7 +933,27 @@ async def scan_document(file_path: str):
|
|||||||
</div>
|
</div>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label class="form-label">Adgangskode *</label>
|
<label class="form-label">Adgangskode *</label>
|
||||||
<input type="password" class="form-control" id="newPassword" required>
|
<input type="password" class="form-control" id="newPassword" required autocomplete="new-password">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Grupper</label>
|
||||||
|
<div id="createUserGroups" class="border rounded p-2" style="max-height: 180px; overflow-y: auto;">
|
||||||
|
<div class="text-muted small">Indlæser grupper...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="newIsSuperadmin">
|
||||||
|
<label class="form-check-label" for="newIsSuperadmin">Superadmin</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="newIsActive" checked>
|
||||||
|
<label class="form-check-label" for="newIsActive">Aktiv</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@ -907,6 +965,76 @@ async def scan_document(file_path: str):
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- User Groups Modal -->
|
||||||
|
<div class="modal fade" id="userGroupsModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="userGroupsModalTitle">Tildel Grupper</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div id="userGroupAssignments" class="border rounded p-2" style="max-height: 240px; overflow-y: auto;">
|
||||||
|
<div class="text-muted small">Indlæser grupper...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
|
||||||
|
<button type="button" class="btn btn-primary" onclick="saveUserGroups()">Gem</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create Group Modal -->
|
||||||
|
<div class="modal fade" id="createGroupModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title">Opret Ny Gruppe</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<form id="createGroupForm">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Navn *</label>
|
||||||
|
<input type="text" class="form-control" id="newGroupName" required>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label class="form-label">Beskrivelse</label>
|
||||||
|
<textarea class="form-control" id="newGroupDescription" rows="3"></textarea>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
|
||||||
|
<button type="button" class="btn btn-primary" onclick="createGroup()">Opret Gruppe</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Group Permissions Modal -->
|
||||||
|
<div class="modal fade" id="groupPermissionsModal" tabindex="-1">
|
||||||
|
<div class="modal-dialog modal-lg">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="groupPermissionsModalTitle">Rediger Rettigheder</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div id="groupPermissionsList" class="border rounded p-2" style="max-height: 360px; overflow-y: auto;">
|
||||||
|
<div class="text-muted small">Indlæser rettigheder...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Annuller</button>
|
||||||
|
<button type="button" class="btn btn-primary" onclick="saveGroupPermissions()">Gem rettigheder</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Pipeline Stage Modal -->
|
<!-- Pipeline Stage Modal -->
|
||||||
<div class="modal fade" id="stageModal" tabindex="-1">
|
<div class="modal fade" id="stageModal" tabindex="-1">
|
||||||
<div class="modal-dialog">
|
<div class="modal-dialog">
|
||||||
@ -1332,6 +1460,9 @@ async function purgeNextcloudAudit() {
|
|||||||
|
|
||||||
function displaySettings(containerId, keys) {
|
function displaySettings(containerId, keys) {
|
||||||
const container = document.getElementById(containerId);
|
const container = document.getElementById(containerId);
|
||||||
|
if (!container) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const settings = allSettings.filter(s => keys.includes(s.key));
|
const settings = allSettings.filter(s => keys.includes(s.key));
|
||||||
|
|
||||||
if (settings.length === 0) {
|
if (settings.length === 0) {
|
||||||
@ -1483,73 +1614,162 @@ async function removeCaseType(type) {
|
|||||||
await saveCaseTypes(filtered);
|
await saveCaseTypes(filtered);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let usersCache = [];
|
||||||
|
let groupsCache = [];
|
||||||
|
let permissionsCache = [];
|
||||||
|
let selectedUserId = null;
|
||||||
|
let selectedGroupId = null;
|
||||||
|
|
||||||
async function loadUsers() {
|
async function loadUsers() {
|
||||||
|
await Promise.all([
|
||||||
|
loadAdminUsers(),
|
||||||
|
loadGroups(),
|
||||||
|
loadPermissions()
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAdminUsers() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/v1/users');
|
const response = await fetch('/api/v1/admin/users');
|
||||||
const users = await response.json();
|
if (!response.ok) throw new Error('Failed to load users');
|
||||||
displayUsers(users);
|
usersCache = await response.json();
|
||||||
|
displayUsers(usersCache);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading users:', error);
|
console.error('Error loading users:', error);
|
||||||
|
const tbody = document.getElementById('usersTableBody');
|
||||||
|
tbody.innerHTML = '<tr><td colspan="6" class="text-center text-muted py-5">Kunne ikke indlæse brugere</td></tr>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadGroups() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/v1/admin/groups');
|
||||||
|
if (!response.ok) throw new Error('Failed to load groups');
|
||||||
|
groupsCache = await response.json();
|
||||||
|
displayGroups(groupsCache);
|
||||||
|
renderGroupCheckboxes('createUserGroups', []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading groups:', error);
|
||||||
|
const tbody = document.getElementById('groupsTableBody');
|
||||||
|
if (tbody) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="3" class="text-center text-muted py-5">Kunne ikke indlæse grupper</td></tr>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPermissions() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/v1/admin/permissions');
|
||||||
|
if (!response.ok) throw new Error('Failed to load permissions');
|
||||||
|
permissionsCache = await response.json();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading permissions:', error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function displayUsers(users) {
|
function displayUsers(users) {
|
||||||
const tbody = document.getElementById('usersTableBody');
|
const tbody = document.getElementById('usersTableBody');
|
||||||
|
|
||||||
if (users.length === 0) {
|
if (!users || users.length === 0) {
|
||||||
tbody.innerHTML = '<tr><td colspan="6" class="text-center text-muted py-5">Ingen brugere fundet</td></tr>';
|
tbody.innerHTML = '<tr><td colspan="6" class="text-center text-muted py-5">Ingen brugere fundet</td></tr>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
tbody.innerHTML = users.map(user => `
|
tbody.innerHTML = users.map(user => {
|
||||||
|
const groupBadges = (user.groups || []).map(name =>
|
||||||
|
`<span class="badge bg-light text-dark border me-1">${escapeHtml(name)}</span>`
|
||||||
|
).join('') || '<span class="text-muted">-</span>';
|
||||||
|
|
||||||
|
return `
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<div class="d-flex align-items-center gap-3">
|
<div class="d-flex align-items-center gap-3">
|
||||||
<div class="user-avatar">${getInitials(user.username)}</div>
|
<div class="user-avatar">${getInitials(user.username)}</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="fw-semibold">${escapeHtml(user.username)}</div>
|
<div class="fw-semibold">
|
||||||
|
${escapeHtml(user.username)}
|
||||||
|
${user.is_superadmin ? '<span class="badge bg-warning text-dark ms-2">Superadmin</span>' : ''}
|
||||||
|
</div>
|
||||||
${user.full_name ? `<small class="text-muted">${escapeHtml(user.full_name)}</small>` : ''}
|
${user.full_name ? `<small class="text-muted">${escapeHtml(user.full_name)}</small>` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>${user.email ? escapeHtml(user.email) : '<span class="text-muted">-</span>'}</td>
|
<td>${user.email ? escapeHtml(user.email) : '<span class="text-muted">-</span>'}</td>
|
||||||
|
<td>${groupBadges}</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="badge ${user.is_active ? 'bg-success' : 'bg-secondary'}">
|
<span class="badge ${user.is_active ? 'bg-success' : 'bg-secondary'}">
|
||||||
${user.is_active ? 'Aktiv' : 'Inaktiv'}
|
${user.is_active ? 'Aktiv' : 'Inaktiv'}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td>${user.last_login ? formatDate(user.last_login) : '<span class="text-muted">Aldrig</span>'}</td>
|
<td>${user.created_at ? formatDate(user.created_at) : '<span class="text-muted">-</span>'}</td>
|
||||||
<td>${formatDate(user.created_at)}</td>
|
|
||||||
<td class="text-end">
|
<td class="text-end">
|
||||||
<div class="btn-group btn-group-sm">
|
<div class="btn-group btn-group-sm">
|
||||||
<button class="btn btn-light" onclick="resetPassword(${user.id})" title="Nulstil adgangskode">
|
<button class="btn btn-light" onclick="openUserGroupsModal(${user.user_id})" title="Tildel grupper">
|
||||||
|
<i class="bi bi-people"></i>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-light" onclick="resetPassword(${user.user_id})" title="Nulstil adgangskode">
|
||||||
<i class="bi bi-key"></i>
|
<i class="bi bi-key"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-light" onclick="toggleUserActive(${user.id}, ${!user.is_active})"
|
<button class="btn btn-light" onclick="toggleUserActive(${user.user_id}, ${!user.is_active})"
|
||||||
title="${user.is_active ? 'Deaktiver' : 'Aktiver'}">
|
title="${user.is_active ? 'Deaktiver' : 'Aktiver'}">
|
||||||
<i class="bi bi-${user.is_active ? 'pause' : 'play'}-circle"></i>
|
<i class="bi bi-${user.is_active ? 'pause' : 'play'}-circle"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayGroups(groups) {
|
||||||
|
const tbody = document.getElementById('groupsTableBody');
|
||||||
|
|
||||||
|
if (!groups || groups.length === 0) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="3" class="text-center text-muted py-5">Ingen grupper fundet</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody.innerHTML = groups.map(group => `
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<div class="fw-semibold">${escapeHtml(group.name)}</div>
|
||||||
|
${group.description ? `<small class="text-muted">${escapeHtml(group.description)}</small>` : ''}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span class="badge bg-light text-dark border">
|
||||||
|
${(group.permissions || []).length} rettigheder
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<button class="btn btn-sm btn-outline-primary" onclick="openGroupPermissionsModal(${group.id})">
|
||||||
|
<i class="bi bi-shield-check me-1"></i>Rediger
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
`).join('');
|
`).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
function showCreateUserModal() {
|
function showCreateUserModal() {
|
||||||
|
renderGroupCheckboxes('createUserGroups', []);
|
||||||
const modal = new bootstrap.Modal(document.getElementById('createUserModal'));
|
const modal = new bootstrap.Modal(document.getElementById('createUserModal'));
|
||||||
modal.show();
|
modal.show();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createUser() {
|
async function createUser() {
|
||||||
|
const selectedGroups = getSelectedGroupIds('createUserGroups');
|
||||||
|
const passwordValue = document.getElementById('newPassword').value;
|
||||||
const user = {
|
const user = {
|
||||||
username: document.getElementById('newUsername').value,
|
username: document.getElementById('newUsername').value,
|
||||||
email: document.getElementById('newEmail').value,
|
email: document.getElementById('newEmail').value,
|
||||||
full_name: document.getElementById('newFullName').value || null,
|
full_name: document.getElementById('newFullName').value || null,
|
||||||
password: document.getElementById('newPassword').value
|
password: passwordValue,
|
||||||
|
is_superadmin: document.getElementById('newIsSuperadmin').checked,
|
||||||
|
is_active: document.getElementById('newIsActive').checked,
|
||||||
|
group_ids: selectedGroups
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/v1/users', {
|
const response = await fetch('/api/v1/admin/users', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(user)
|
body: JSON.stringify(user)
|
||||||
@ -1558,10 +1778,10 @@ async function createUser() {
|
|||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
bootstrap.Modal.getInstance(document.getElementById('createUserModal')).hide();
|
bootstrap.Modal.getInstance(document.getElementById('createUserModal')).hide();
|
||||||
document.getElementById('createUserForm').reset();
|
document.getElementById('createUserForm').reset();
|
||||||
|
renderGroupCheckboxes('createUserGroups', []);
|
||||||
loadUsers();
|
loadUsers();
|
||||||
} else {
|
} else {
|
||||||
const error = await response.json();
|
alert(await getErrorMessage(response, 'Kunne ikke oprette bruger'));
|
||||||
alert(error.detail || 'Kunne ikke oprette bruger');
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error creating user:', error);
|
console.error('Error creating user:', error);
|
||||||
@ -1585,6 +1805,180 @@ async function toggleUserActive(userId, isActive) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function showCreateGroupModal() {
|
||||||
|
const modal = new bootstrap.Modal(document.getElementById('createGroupModal'));
|
||||||
|
modal.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createGroup() {
|
||||||
|
const payload = {
|
||||||
|
name: document.getElementById('newGroupName').value,
|
||||||
|
description: document.getElementById('newGroupDescription').value || null
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!payload.name) {
|
||||||
|
alert('Navn er påkrævet');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/v1/admin/groups', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
bootstrap.Modal.getInstance(document.getElementById('createGroupModal')).hide();
|
||||||
|
document.getElementById('createGroupForm').reset();
|
||||||
|
loadGroups();
|
||||||
|
} else {
|
||||||
|
alert(await getErrorMessage(response, 'Kunne ikke oprette gruppe'));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating group:', error);
|
||||||
|
alert('Kunne ikke oprette gruppe');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openUserGroupsModal(userId) {
|
||||||
|
const user = usersCache.find(u => u.user_id === userId);
|
||||||
|
if (!user) return;
|
||||||
|
selectedUserId = userId;
|
||||||
|
|
||||||
|
document.getElementById('userGroupsModalTitle').textContent = `Tildel grupper • ${user.username}`;
|
||||||
|
const selectedIds = (user.groups || [])
|
||||||
|
.map(name => {
|
||||||
|
const group = groupsCache.find(g => g.name === name);
|
||||||
|
return group ? group.id : null;
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
renderGroupCheckboxes('userGroupAssignments', selectedIds);
|
||||||
|
|
||||||
|
const modal = new bootstrap.Modal(document.getElementById('userGroupsModal'));
|
||||||
|
modal.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveUserGroups() {
|
||||||
|
if (!selectedUserId) return;
|
||||||
|
const groupIds = getSelectedGroupIds('userGroupAssignments');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/v1/admin/users/${selectedUserId}/groups`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ group_ids: groupIds })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
alert(await getErrorMessage(response, 'Kunne ikke opdatere grupper'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
bootstrap.Modal.getInstance(document.getElementById('userGroupsModal')).hide();
|
||||||
|
loadAdminUsers();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating user groups:', error);
|
||||||
|
alert('Kunne ikke opdatere grupper');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openGroupPermissionsModal(groupId) {
|
||||||
|
const group = groupsCache.find(g => g.id === groupId);
|
||||||
|
if (!group) return;
|
||||||
|
selectedGroupId = groupId;
|
||||||
|
|
||||||
|
document.getElementById('groupPermissionsModalTitle').textContent = `Rettigheder • ${group.name}`;
|
||||||
|
renderPermissionsChecklist(group.permissions || []);
|
||||||
|
|
||||||
|
const modal = new bootstrap.Modal(document.getElementById('groupPermissionsModal'));
|
||||||
|
modal.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveGroupPermissions() {
|
||||||
|
if (!selectedGroupId) return;
|
||||||
|
const permissionIds = Array.from(document.querySelectorAll('#groupPermissionsList input[type="checkbox"]:checked'))
|
||||||
|
.map(input => parseInt(input.dataset.permissionId));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/v1/admin/groups/${selectedGroupId}/permissions`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ permission_ids: permissionIds })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
alert(await getErrorMessage(response, 'Kunne ikke opdatere rettigheder'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
bootstrap.Modal.getInstance(document.getElementById('groupPermissionsModal')).hide();
|
||||||
|
loadGroups();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating group permissions:', error);
|
||||||
|
alert('Kunne ikke opdatere rettigheder');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGroupCheckboxes(containerId, selectedIds = []) {
|
||||||
|
const container = document.getElementById(containerId);
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
if (!groupsCache || groupsCache.length === 0) {
|
||||||
|
container.innerHTML = '<div class="text-muted small">Ingen grupper fundet</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = groupsCache.map(group => `
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="${containerId}_${group.id}" data-group-id="${group.id}"
|
||||||
|
${selectedIds.includes(group.id) ? 'checked' : ''}>
|
||||||
|
<label class="form-check-label" for="${containerId}_${group.id}">
|
||||||
|
${escapeHtml(group.name)}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPermissionsChecklist(selectedCodes = []) {
|
||||||
|
const container = document.getElementById('groupPermissionsList');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
if (!permissionsCache || permissionsCache.length === 0) {
|
||||||
|
container.innerHTML = '<div class="text-muted small">Ingen rettigheder fundet</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const grouped = permissionsCache.reduce((acc, perm) => {
|
||||||
|
const key = perm.category || 'andet';
|
||||||
|
acc[key] = acc[key] || [];
|
||||||
|
acc[key].push(perm);
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
container.innerHTML = Object.entries(grouped).map(([category, perms]) => `
|
||||||
|
<div class="mb-3">
|
||||||
|
<div class="fw-semibold text-uppercase small text-muted mb-2">${escapeHtml(category)}</div>
|
||||||
|
${perms.map(perm => `
|
||||||
|
<div class="form-check">
|
||||||
|
<input class="form-check-input" type="checkbox" id="perm_${perm.id}" data-permission-id="${perm.id}"
|
||||||
|
${selectedCodes.includes(perm.code) ? 'checked' : ''}>
|
||||||
|
<label class="form-check-label" for="perm_${perm.id}">
|
||||||
|
<span class="fw-semibold">${escapeHtml(perm.code)}</span>
|
||||||
|
${perm.description ? `<small class="text-muted d-block">${escapeHtml(perm.description)}</small>` : ''}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
`).join('')}
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSelectedGroupIds(containerId) {
|
||||||
|
return Array.from(document.querySelectorAll(`#${containerId} input[type="checkbox"]:checked`))
|
||||||
|
.map(input => parseInt(input.dataset.groupId));
|
||||||
|
}
|
||||||
|
|
||||||
async function resetPassword(userId) {
|
async function resetPassword(userId) {
|
||||||
const newPassword = prompt('Indtast ny adgangskode:');
|
const newPassword = prompt('Indtast ny adgangskode:');
|
||||||
if (!newPassword) return;
|
if (!newPassword) return;
|
||||||
@ -1807,6 +2201,26 @@ function escapeHtml(text) {
|
|||||||
return div.innerHTML;
|
return div.innerHTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getErrorMessage(response, fallback) {
|
||||||
|
try {
|
||||||
|
const text = await response.text();
|
||||||
|
if (text) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(text);
|
||||||
|
if (data && data.detail) return data.detail;
|
||||||
|
if (data && data.message) return data.message;
|
||||||
|
} catch (error) {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// ignore body read errors
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusLabel = response.status ? ` (${response.status} ${response.statusText || ''})` : '';
|
||||||
|
return `${fallback}${statusLabel}`.trim();
|
||||||
|
}
|
||||||
|
|
||||||
function formatDate(dateString) {
|
function formatDate(dateString) {
|
||||||
const date = new Date(dateString);
|
const date = new Date(dateString);
|
||||||
return date.toLocaleDateString('da-DK', {
|
return date.toLocaleDateString('da-DK', {
|
||||||
@ -2772,7 +3186,8 @@ async function loadEmailTemplateCustomers() {
|
|||||||
// Populate customer dropdowns (Filter and Modal)
|
// Populate customer dropdowns (Filter and Modal)
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/v1/customers');
|
const response = await fetch('/api/v1/customers');
|
||||||
const customers = await response.json();
|
const payload = await response.json();
|
||||||
|
const customers = Array.isArray(payload) ? payload : (payload.customers || []);
|
||||||
|
|
||||||
const filterSelect = document.getElementById('emailTemplateCustomerFilter');
|
const filterSelect = document.getElementById('emailTemplateCustomerFilter');
|
||||||
const modalSelect = document.getElementById('emailTemplateCustomer');
|
const modalSelect = document.getElementById('emailTemplateCustomer');
|
||||||
|
|||||||
@ -39,6 +39,7 @@ services:
|
|||||||
- ./uploads:/app/uploads
|
- ./uploads:/app/uploads
|
||||||
- ./static:/app/static
|
- ./static:/app/static
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
|
- ./migrations:/app/migrations:ro
|
||||||
# Mount for local development - live code reload
|
# Mount for local development - live code reload
|
||||||
- ./app:/app/app:ro
|
- ./app:/app/app:ro
|
||||||
- ./main.py:/app/main.py:ro
|
- ./main.py:/app/main.py:ro
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user