Add demo system and various analysis scripts

- Created demo_system.sh for interactive demo scenarios including TAG and B7 analysis, monthly economics, and complete system demo.
- Implemented legacy_tag_opgaver.sh to fetch and analyze real API data for TAG tasks, generating detailed CSV reports.
- Developed oekonomi_analyser.sh for economic analysis with options to fetch new data and specify the year for analysis.
- Introduced tag_analyser.sh for analyzing TAG tasks with options for fetching data and specifying task types.
This commit is contained in:
2025-09-19 10:59:48 +02:00
parent cd6cece9da
commit 1615ec92c1
9 changed files with 1960 additions and 471 deletions
+132
View File
@@ -0,0 +1,132 @@
#!/usr/bin/env bash
# Demo System - Tilbudsgivern Server
SCRIPT_DIR="$(dirname "$0")"
# Farver til demo
RED='\033[0;31m'
GREEN='\033[0;32m'
BLUE='\033[0;34m'
YELLOW='\033[1;33m'
CYAN='\033[0;36m'
NC='\033[0m' # No Color
print_banner() {
echo -e "${BLUE}============================================${NC}"
echo -e "${BLUE} TILBUDSGIVERN ANALYSE SYSTEM DEMO${NC}"
echo -e "${BLUE}============================================${NC}"
echo ""
}
demo_menu() {
echo -e "${CYAN}Vælg demo scenario:${NC}"
echo ""
echo "1. TAG Opgaver Analyse"
echo "2. B7 Opgaver Analyse"
echo "3. Månedlig Økonomi 2025"
echo "4. Komplet System Demo"
echo "5. Afslut"
echo ""
read -p "Vælg (1-5): " choice
echo ""
case $choice in
1)
demo_tag_analysis
;;
2)
demo_b7_analysis
;;
3)
demo_monthly_economics
;;
4)
demo_complete_system
;;
5)
echo -e "${GREEN}Demo afsluttet${NC}"
exit 0
;;
*)
echo -e "${RED}Ugyldigt valg${NC}"
demo_menu
;;
esac
}
demo_tag_analysis() {
echo -e "${GREEN}=== TAG OPGAVER ANALYSE DEMO ===${NC}"
echo "Simulerer: ./tag_analyser.sh --key API_KEY --task-type tag"
echo ""
echo -e "${YELLOW}Resultater:${NC}"
echo "- 400+ TAG opgaver identificeret"
echo "- 2 tilbud sendt (0.5% conversion rate)"
echo "- 81.75 timer registreret"
echo "- CSV rapport genereret"
echo ""
read -p "Tryk Enter for at fortsætte..."
demo_menu
}
demo_b7_analysis() {
echo -e "${GREEN}=== B7 OPGAVER ANALYSE DEMO ===${NC}"
echo "Simulerer: ./tag_analyser.sh --key API_KEY --task-type b7"
echo ""
echo -e "${YELLOW}Resultater:${NC}"
echo "- 6 B7 opgaver identificeret"
echo "- 0 tilbud sendt (100% missed opportunity)"
echo "- Case 2404: 450 dage gammel uden tilbud!"
echo "- Potentiale: 480.000 DKK"
echo ""
read -p "Tryk Enter for at fortsætte..."
demo_menu
}
demo_monthly_economics() {
echo -e "${GREEN}=== MÅNEDLIG ØKONOMI 2025 DEMO ===${NC}"
echo "Simulerer: ./oekonomi_analyser.sh --key API_KEY --year 2025"
echo ""
echo -e "${YELLOW}Resultater:${NC}"
echo "- 114 cases registreret i 2025"
echo "- 5.985.000 DKK total værdi"
echo "- Kun 120.000 DKK realiseret (2%!)"
echo "- 36 aktive cases"
echo "- September: 100% aktive cases"
echo ""
read -p "Tryk Enter for at fortsætte..."
demo_menu
}
demo_complete_system() {
echo -e "${GREEN}=== KOMPLET SYSTEM DEMO ===${NC}"
echo ""
echo -e "${CYAN}1. Data Indsamling${NC}"
echo " - Henter alle cases fra API"
echo " - Henter timer registreringer"
echo " - Henter tilbuds data"
echo ""
echo -e "${CYAN}2. Analyse${NC}"
echo " - TAG opgaver: 400+ cases"
echo " - B7 opgaver: 6 cases"
echo " - Økonomi: 114 cases i 2025"
echo ""
echo -e "${CYAN}3. Rapporter${NC}"
echo " - CSV filer til Excel"
echo " - Detaljerede tekst rapporter"
echo " - Månedlige oversigter"
echo ""
echo -e "${RED}KRITISKE FUND:${NC}"
echo " - 99.8 millioner DKK tabt potentiale"
echo " - 0.7% conversion rate"
echo " - 398 TAG cases uden tilbud"
echo ""
read -p "Tryk Enter for at fortsætte..."
demo_menu
}
main() {
print_banner
demo_menu
}
main "$@"
+240
View File
@@ -0,0 +1,240 @@
#!/usr/bin/env bash
# TAG Opgaver - KUN VIRKELIG API DATA
# Ingen estimater, kun faktiske data fra API endpoints
set -e
OUT_DIR="./ordrestyring_export"
echo "=== TAG OPGAVER - KUN VIRKELIG API DATA ==="
echo "Dato: $(date)"
echo
# Tjek at vi har API data
if [[ ! -f "$OUT_DIR/cases.jsonl" ]]; then
echo "Henter API data først..."
./ordrecheck_simple.sh --key cRD7xfeoiGh1OhzV
fi
# Find TAG opgaver fra cases API
echo "Finder TAG opgaver fra all_cases.jsonl..."
if [[ -f "$OUT_DIR/all_cases.jsonl" ]]; then
grep -i "tag\|eternit\|tegl\|rende\|velux\|rygning" "$OUT_DIR/all_cases.jsonl" > "$OUT_DIR/tag_cases.jsonl" 2>/dev/null || echo -n ""
elif [[ -f "$OUT_DIR/cases_with_offers.jsonl" ]]; then
grep -i "tag\|eternit\|tegl\|rende\|velux\|rygning" "$OUT_DIR/cases_with_offers.jsonl" > "$OUT_DIR/tag_cases.jsonl" 2>/dev/null || echo -n ""
else
echo "Ingen cases data fundet"
exit 1
fi
tag_count=$(wc -l < "$OUT_DIR/tag_cases.jsonl" 2>/dev/null || echo "0")
echo "Fundet $tag_count TAG opgaver"
if [[ $tag_count -eq 0 ]]; then
echo "Ingen TAG opgaver fundet i API data"
exit 1
fi
# Generer komplet CSV med alle virkelige data
generate_complete_csv() {
local csv_file="$OUT_DIR/tag_opgaver_komplet_API_data.csv"
echo "case_number,customer_number,creation_date,description,status,offer_number,main_technician,additional_technicians,yourref,requestor,work_done,remarks,department,no_materials,sub_number,case_type,delivery_address,contact,hours_registered,materials_registered,costprice_total_oere,offer_lines_count" > "$csv_file"
local total_cases=0
local cases_with_offers=0
local cases_with_hours=0
local cases_with_materials=0
echo "Genererer CSV med alle API felter..."
while IFS= read -r case_line; do
# Hent alle case felter fra API
local case_number=$(echo "$case_line" | jq -r '.case_number // "N/A"')
local customer_number=$(echo "$case_line" | jq -r '.customer_number // "N/A"')
local creation_date=$(echo "$case_line" | jq -r '.creation_date // .created_at // "N/A"')
local description=$(echo "$case_line" | jq -r '.description // ""' | tr '"' "'" | tr '\n\r' ' ')
local status=$(echo "$case_line" | jq -r '.status // "N/A"')
local offer_number=$(echo "$case_line" | jq -r '.offer_number // ""')
local main_technician=$(echo "$case_line" | jq -r '.main_technician // "N/A"')
local additional_technicians=$(echo "$case_line" | jq -r '.additional_technicians // ""' | tr '"' "'" | tr '\n\r' ' ')
local yourref=$(echo "$case_line" | jq -r '.yourref // ""' | tr '"' "'" | tr '\n\r' ' ')
local requestor=$(echo "$case_line" | jq -r '.requestor // ""' | tr '"' "'" | tr '\n\r' ' ')
local work_done=$(echo "$case_line" | jq -r '.work_done // ""' | tr '"' "'" | tr '\n\r' ' ')
local remarks=$(echo "$case_line" | jq -r '.remarks // ""' | tr '"' "'" | tr '\n\r' ' ')
local department=$(echo "$case_line" | jq -r '.department // "N/A"')
local no_materials=$(echo "$case_line" | jq -r '.no_materials // false')
local sub_number=$(echo "$case_line" | jq -r '.sub_number // "N/A"')
local case_type=$(echo "$case_line" | jq -r '.case_type // "N/A"')
local delivery_address=$(echo "$case_line" | jq -r '.delivery_address // "N/A"')
local contact=$(echo "$case_line" | jq -r '.contact // ""' | tr '"' "'" | tr '\n\r' ' ')
total_cases=$((total_cases + 1))
# Konverter dato til læsbar format
local readable_date="N/A"
if [[ "$creation_date" != "N/A" && "$creation_date" != "null" && $creation_date -gt 0 ]]; then
readable_date=$(date -d "@$creation_date" '+%Y-%m-%d %H:%M' 2>/dev/null || echo "$creation_date")
fi
# Tjek om der er tilbud
local has_offer="Nej"
if [[ -n "$offer_number" && "$offer_number" != "null" && "$offer_number" != "N/A" && "$offer_number" != "" ]]; then
has_offer="Ja"
cases_with_offers=$((cases_with_offers + 1))
fi
# Tjek timer fra hours API
local hours_registered=0
local costprice_total=0
if [[ -f "$OUT_DIR/hours.jsonl" ]]; then
while IFS= read -r hour_line; do
local hour_case=$(echo "$hour_line" | jq -r '.new_case_number // ""')
if [[ "$hour_case" == "$case_number" ]]; then
local start_time=$(echo "$hour_line" | jq -r '.start_time // 0')
local stop_time=$(echo "$hour_line" | jq -r '.stop_time // 0')
local costprice=$(echo "$hour_line" | jq -r '.costprice // 0')
if [[ $start_time -gt 0 && $stop_time -gt 0 ]]; then
local duration=$((stop_time - start_time))
local hours=$((duration / 3600))
hours_registered=$((hours_registered + hours))
costprice_total=$((costprice_total + costprice))
fi
fi
done < "$OUT_DIR/hours.jsonl"
fi
if [[ $hours_registered -gt 0 ]]; then
cases_with_hours=$((cases_with_hours + 1))
fi
# Tjek materialer fra case-materials API (hvis tilgængeligt)
local materials_registered=0
if [[ -f "$OUT_DIR/materials.jsonl" ]]; then
materials_registered=$(grep "\"case_number\":\"$case_number\"" "$OUT_DIR/materials.jsonl" 2>/dev/null | wc -l)
if [[ $materials_registered -gt 0 ]]; then
cases_with_materials=$((cases_with_materials + 1))
fi
fi
# Tjek offer lines (hvis tilgængeligt)
local offer_lines_count=0
if [[ -n "$offer_number" && "$offer_number" != "null" && -f "$OUT_DIR/offer_${offer_number}_lines.json" ]]; then
offer_lines_count=$(jq '. | length' "$OUT_DIR/offer_${offer_number}_lines.json" 2>/dev/null || echo "0")
fi
# Skriv til CSV (alle felter fra API)
echo "$case_number,$customer_number,\"$readable_date\",\"$description\",$status,\"$offer_number\",$main_technician,\"$additional_technicians\",\"$yourref\",\"$requestor\",\"$work_done\",\"$remarks\",$department,$no_materials,$sub_number,$case_type,$delivery_address,\"$contact\",$hours_registered,$materials_registered,$costprice_total,$offer_lines_count" >> "$csv_file"
done < "$OUT_DIR/tag_cases.jsonl"
echo
echo "=========================================="
echo "TAG OPGAVER STATISTIK (API DATA)"
echo "=========================================="
printf "Total TAG opgaver: %d\n" $total_cases
printf "Cases med tilbud: %d\n" $cases_with_offers
printf "Cases med timer: %d\n" $cases_with_hours
printf "Cases med materialer: %d\n" $cases_with_materials
if [[ $total_cases -gt 0 ]]; then
local offer_pct=$(echo "scale=1; $cases_with_offers * 100 / $total_cases" | bc -l 2>/dev/null || echo "0")
local hours_pct=$(echo "scale=1; $cases_with_hours * 100 / $total_cases" | bc -l 2>/dev/null || echo "0")
local materials_pct=$(echo "scale=1; $cases_with_materials * 100 / $total_cases" | bc -l 2>/dev/null || echo "0")
printf "Tilbuds rate: %s%%\n" "$offer_pct"
printf "Timer rate: %s%%\n" "$hours_pct"
printf "Materialer rate: %s%%\n" "$materials_pct"
fi
echo
echo "CSV fil genereret: $csv_file"
echo "Åbn i Excel for komplet oversigt af alle TAG opgaver"
}
# Generer også en detaljeret timer rapport
generate_hours_details() {
local hours_file="$OUT_DIR/tag_timer_detaljer_API.csv"
echo "case_number,emp_id,start_time,stop_time,hours_worked,remark,costprice_oere,costprice_dkk,hour_type,customer_internal_number" > "$hours_file"
if [[ ! -f "$OUT_DIR/hours.jsonl" ]]; then
echo "Ingen timer data tilgængeligt"
return
fi
echo "Genererer detaljeret timer rapport..."
local total_tag_hours=0
local total_tag_costprice=0
# Find timer for TAG cases
while IFS= read -r case_line; do
local case_number=$(echo "$case_line" | jq -r '.case_number // "N/A"')
# Find timer for denne case
while IFS= read -r hour_line; do
local hour_case=$(echo "$hour_line" | jq -r '.new_case_number // ""')
if [[ "$hour_case" == "$case_number" ]]; then
local emp_id=$(echo "$hour_line" | jq -r '.emp_id // "N/A"')
local start_time=$(echo "$hour_line" | jq -r '.start_time // 0')
local stop_time=$(echo "$hour_line" | jq -r '.stop_time // 0')
local remark=$(echo "$hour_line" | jq -r '.remark // ""' | tr '"' "'" | tr '\n\r' ' ')
local costprice=$(echo "$hour_line" | jq -r '.costprice // 0')
local hour_type=$(echo "$hour_line" | jq -r '.hour_type // "N/A"')
local customer_internal_number=$(echo "$hour_line" | jq -r '.customer_internal_number // ""')
if [[ $start_time -gt 0 && $stop_time -gt 0 ]]; then
local duration=$((stop_time - start_time))
local hours_worked=$(echo "scale=2; $duration / 3600" | bc -l 2>/dev/null || echo "0")
local costprice_dkk=$(echo "scale=2; $costprice / 100" | bc -l 2>/dev/null || echo "0")
# Konverter timestamps
local start_readable=$(date -d "@$start_time" '+%Y-%m-%d %H:%M' 2>/dev/null || echo "$start_time")
local stop_readable=$(date -d "@$stop_time" '+%Y-%m-%d %H:%M' 2>/dev/null || echo "$stop_time")
total_tag_hours=$(echo "$total_tag_hours + $hours_worked" | bc -l 2>/dev/null || echo "$total_tag_hours")
total_tag_costprice=$((total_tag_costprice + costprice))
echo "$case_number,$emp_id,\"$start_readable\",\"$stop_readable\",$hours_worked,\"$remark\",$costprice,$costprice_dkk,$hour_type,\"$customer_internal_number\"" >> "$hours_file"
fi
fi
done < "$OUT_DIR/hours.jsonl"
done < "$OUT_DIR/tag_cases.jsonl"
local total_costprice_dkk=$(echo "scale=2; $total_tag_costprice / 100" | bc -l 2>/dev/null || echo "0")
echo
echo "=========================================="
echo "TAG TIMER STATISTIK (API DATA)"
echo "=========================================="
echo "Total timer på TAG opgaver: $total_tag_hours timer"
echo "Total kostpris: $total_tag_costprice øre ($total_costprice_dkk DKK)"
echo
echo "Timer detaljer CSV: $hours_file"
}
# Kør alle rapporter
generate_complete_csv
echo
generate_hours_details
echo
echo "=========================================="
echo "ALLE FILER GENERERET - KUN API DATA"
echo "=========================================="
echo "1. $OUT_DIR/tag_opgaver_komplet_API_data.csv - Komplet oversigt"
echo "2. $OUT_DIR/tag_timer_detaljer_API.csv - Timer detaljer"
echo
echo "VIGTIGE KOLONNER I CSV:"
echo "• case_number - API case nummer"
echo "• customer_number - API kunde nummer"
echo "• creation_date - faktisk oprettelsesdato"
echo "• description - opgave beskrivelse fra API"
echo "• status - API status kode"
echo "• offer_number - tilbudsnummer (tom hvis intet)"
echo "• hours_registered - faktiske timer fra hours API"
echo "• costprice_total_oere - faktisk kostpris fra hours API"
echo "• materials_registered - antal materialer fra API"
echo
echo "BEMÆRK: Kun faktiske data fra API - ingen estimater!"
echo "Åbn CSV filerne i Excel for komplet analyse"
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env bash
# Økonomi Analyser - Server version
SCRIPT_DIR="$(dirname "$0")"
source "$SCRIPT_DIR/../functions/api_client.sh"
source "$SCRIPT_DIR/../functions/analyzer.sh"
usage() {
echo "Økonomi Analyser - Tilbudsgivern Server"
echo "Usage: $0 [OPTIONS]"
echo ""
echo "OPTIONS:"
echo " --key API_KEY API nøgle (påkrævet)"
echo " --year YEAR År til analyse (default: 2025)"
echo " --update Hent nye data fra API"
echo " --help Vis denne hjælp"
echo ""
echo "EKSEMPLER:"
echo " $0 --key ABC123 --year 2025"
echo " $0 --key ABC123 --year 2024 --update"
}
main() {
local api_key=""
local year="2025"
local update_data=false
# Parse argumenter
while [[ $# -gt 0 ]]; do
case $1 in
--key)
api_key="$2"
shift 2
;;
--year)
year="$2"
shift 2
;;
--update)
update_data=true
shift
;;
--help)
usage
exit 0
;;
*)
echo "Ukendt option: $1"
usage
exit 1
;;
esac
done
if [[ -z "$api_key" ]]; then
echo "API nøgle mangler"
usage
exit 1
fi
# Opret mapper
mkdir -p "$DATA_DIR" "$REPORTS_DIR"
# Hent data hvis requested
if [[ "$update_data" == true ]]; then
echo "=== HENTER DATA FRA API ==="
fetch_all_cases "$api_key"
fetch_hours_data "$api_key"
echo ""
fi
echo "=== ANALYSERER ØKONOMI FOR $year ==="
generate_monthly_economics "$year"
echo ""
echo "=== ØKONOMI ANALYSE FULDFØRT ==="
echo "Data dir: $DATA_DIR"
echo "Rapporter dir: $REPORTS_DIR"
}
main "$@"
+86
View File
@@ -0,0 +1,86 @@
#!/usr/bin/env bash
# TAG Opgaver Analyser - Server version
SCRIPT_DIR="$(dirname "$0")"
source "$SCRIPT_DIR/../functions/api_client.sh"
source "$SCRIPT_DIR/../functions/analyzer.sh"
usage() {
echo "TAG Analyser - Tilbudsgivern Server"
echo "Usage: $0 [OPTIONS]"
echo ""
echo "OPTIONS:"
echo " --key API_KEY API nøgle (påkrævet)"
echo " --update Hent nye data fra API"
echo " --task-type TYPE Analyser specifik opgave type"
echo " --help Vis denne hjælp"
echo ""
echo "EKSEMPLER:"
echo " $0 --key ABC123 --update"
echo " $0 --key ABC123 --task-type tag"
echo " $0 --key ABC123 --task-type b7"
}
main() {
local api_key=""
local update_data=false
local task_type=""
# Parse argumenter
while [[ $# -gt 0 ]]; do
case $1 in
--key)
api_key="$2"
shift 2
;;
--update)
update_data=true
shift
;;
--task-type)
task_type="$2"
shift 2
;;
--help)
usage
exit 0
;;
*)
echo "Ukendt option: $1"
usage
exit 1
;;
esac
done
if [[ -z "$api_key" ]]; then
echo "API nøgle mangler"
usage
exit 1
fi
# Opret mapper
mkdir -p "$DATA_DIR" "$REPORTS_DIR"
# Hent data hvis requested
if [[ "$update_data" == true ]]; then
echo "=== HENTER DATA FRA API ==="
fetch_all_cases "$api_key"
fetch_hours_data "$api_key"
echo ""
fi
# Analyser opgave type
if [[ -n "$task_type" ]]; then
echo "=== ANALYSERER $task_type OPGAVER ==="
search_task_type "$task_type"
analyze_task_type "$task_type"
echo ""
fi
echo "=== TAG ANALYSER FULDFØRT ==="
echo "Data dir: $DATA_DIR"
echo "Rapporter dir: $REPORTS_DIR"
}
main "$@"
+11
View File
@@ -35,6 +35,11 @@ else
error "Database connection failed"
fi
# Step 1.5: Flush PM2 logs
info "Step 1.5: Flushing old logs..."
pm2 flush tilbudgivern-unified > /dev/null 2>&1
success "Logs flushed"
# Step 2: Backend API Test
info "Step 2: Testing backend APIs..."
if curl -s https://tilbudsgiveren.alw.dk/api/categories/list | jq -e '.success' > /dev/null 2>&1; then
@@ -101,3 +106,9 @@ echo "🌐 Access your application at: https://tilbudsgiveren.alw.dk"
echo "🔍 Monitor with: pm2 monit"
echo "📋 View logs with: pm2 logs tilbudgivern-unified"
echo ""
# Step 7: Show recent logs for review
echo "📋 Recent Logs (last 10 lines):"
echo "================================"
pm2 logs tilbudgivern-unified --lines 10 --nostream
echo ""
+1 -13
View File
@@ -5,13 +5,12 @@ import ProjectFlow from './components/ProjectFlow';
import ProjectReview from './components/ProjectReview';
import CompletedQuotes from './components/CompletedQuotes';
import MaterialsList from './MaterialsList';
import BygmaProducts from './BygmaProducts';
import './components/ProjectReview.css';
const API_BASE_URL = process.env.REACT_APP_API_URL || 'http://localhost:4030';
function App() {
const [currentView, setCurrentView] = useState('projects'); // 'projects', 'project-review', 'completed-quotes', 'materials', 'bygma-products', 'openai-usage'
const [currentView, setCurrentView] = useState('projects'); // 'projects', 'project-review', 'completed-quotes', 'materials', 'openai-usage'
// Authentication state
const [isAuthenticated, setIsAuthenticated] = useState(false);
@@ -150,14 +149,6 @@ function App() {
>
🧱 Materialer
</button>
<button
className={`nav-btn ${currentView === 'bygma-products' ? 'active' : ''}`}
onClick={() => {
setCurrentView('bygma-products');
}}
>
🏗 Bygma Produkter
</button>
<button
className={`nav-btn ${currentView === 'openai-usage' ? 'active' : ''}`}
onClick={() => {
@@ -192,9 +183,6 @@ function App() {
) : currentView === 'materials' ? (
// Materials Management with new design
<MaterialsList apiBaseUrl={API_BASE_URL} />
) : currentView === 'bygma-products' ? (
// Bygma Products Catalog
<BygmaProducts apiBaseUrl={API_BASE_URL} />
) : currentView === 'openai-usage' ? (
// OpenAI Usage section
<div className="openai-usage-view">
+521 -56
View File
@@ -1,12 +1,18 @@
/* MaterialsList.css - Based on CompletedQuotes design patterns */
/* GLOBAL CENTER ALIGNMENT OVERRIDE */
* {
text-align: inherit;
}
.materials-list-view {
padding: 0;
width: 100%;
max-width: 100%;
width: 100vw;
max-width: 100vw;
margin: 0;
background: #f8f9fa;
min-height: 100vh;
box-sizing: border-box;
}
.materials-header {
@@ -244,25 +250,127 @@
overflow: auto;
border-radius: 0;
position: relative;
width: 100%;
margin: 0;
padding: 0;
}
/* Tabelstyling med moderne design */
.materials-table {
width: 100%;
border-collapse: collapse;
font-size: 14px;
background: white;
border-radius: 0 0 12px 12px;
overflow: hidden;
box-shadow: 0 4px 6px rgba(0,0,0,0.07);
margin: 0;
table-layout: fixed;
}
.materials-table th {
background: #f8f9fa;
padding: 16px 12px;
text-align: left;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 18px 16px;
text-align: center;
font-weight: 600;
font-size: 14px;
text-transform: uppercase;
letter-spacing: 0.8px;
border: none;
position: relative;
}
.materials-table th:first-child {
border-radius: 0;
}
.materials-table th:last-child {
border-radius: 0;
}
.materials-table td {
padding: 16px;
border-bottom: 1px solid #f1f3f4;
font-size: 14px;
line-height: 1.5;
vertical-align: middle;
text-align: center;
transition: background-color 0.2s ease;
}
.materials-table tr:hover td {
background-color: rgba(102, 126, 234, 0.04);
}
.materials-table tr:last-child td {
border-bottom: none;
}
/* Specifik cellestyling for forskellige kolonner - FORCE CENTER ALIGNMENT */
.materials-table td:nth-child(1),
.materials-table td.material-name {
/* Materiale navn */
font-weight: 600;
color: #2c3e50;
border-bottom: 2px solid #e0e0e0;
position: sticky;
top: 0;
z-index: 10;
background-color: rgba(102, 126, 234, 0.03);
border-left: 4px solid #667eea;
max-width: 300px;
word-wrap: break-word;
white-space: pre-wrap;
text-align: center !important;
}
.materials-table td:nth-child(2) {
/* Kategori */
color: #6c757d;
font-size: 13px;
background-color: rgba(134, 142, 150, 0.05);
text-transform: capitalize;
text-align: center !important;
}
.materials-table td:nth-child(3),
.materials-table td.supplier-cell {
/* Leverandør */
color: #495057;
font-weight: 500;
background-color: rgba(73, 80, 87, 0.03);
text-align: center !important;
}
.materials-table td:nth-child(4),
.materials-table td.price-cell {
/* Pris */
font-weight: 700;
color: #28a745;
text-align: center !important;
background-color: rgba(40, 167, 69, 0.05);
font-family: 'Segoe UI', monospace;
}
.materials-table td:nth-child(5),
.materials-table td.unit-cell {
/* Enhed */
color: #6c757d;
font-size: 13px;
text-align: center !important;
background-color: rgba(134, 142, 150, 0.05);
}
.materials-table td:nth-child(6),
.materials-table td.description-cell {
/* Beskrivelse */
color: #495057;
text-align: center !important;
background-color: rgba(248, 249, 250, 0.3);
}
.materials-table td:nth-child(7),
.materials-table td.actions {
/* Handlinger */
text-align: center !important;
background-color: rgba(248, 249, 250, 0.8);
width: 140px;
}
/* Sticky actions column */
@@ -270,14 +378,8 @@
position: sticky;
right: 0;
z-index: 11;
background: #f8f9fa;
box-shadow: -2px 0 4px rgba(0,0,0,0.1);
}
.materials-table td {
padding: 12px;
border-bottom: 1px solid #f0f0f0;
vertical-align: middle;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
box-shadow: -3px 0 6px rgba(0,0,0,0.1);
}
/* Sticky actions column for data cells */
@@ -285,19 +387,31 @@
position: sticky;
right: 0;
background: white;
box-shadow: -2px 0 4px rgba(0,0,0,0.1);
box-shadow: -3px 0 6px rgba(0,0,0,0.05);
border-left: 1px solid #f1f3f5;
text-align: center !important;
}
.material-row {
transition: all 0.3s ease;
}
.material-row:hover {
background: #f8f9fa;
background: linear-gradient(135deg, #f8f9ff 0%, #f0f4ff 100%);
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.15);
}
.material-row:hover td:last-child {
background: #f8f9fa;
background: linear-gradient(135deg, #f8f9ff 0%, #f0f4ff 100%);
}
.material-row:nth-child(even) {
background: #fafbfc;
background: #fbfcfd;
}
.material-row:nth-child(even):hover {
background: linear-gradient(135deg, #f8f9ff 0%, #f0f4ff 100%);
}
.material-row:nth-child(even) td:last-child {
@@ -312,58 +426,102 @@
background: #f0f2f5;
}
/* Column widths */
/* Column widths - matcher CSV struktur: Produktnavn, Kategori, Leverandør, Enhedspris, Enhed, Beskrivelse, Handlinger */
.materials-table th:nth-child(1),
.materials-table td:nth-child(1) { width: 22%; } /* Material name */
.materials-table td:nth-child(1) { width: 25%; } /* Produktnavn - større for multi-linje */
.materials-table th:nth-child(2),
.materials-table td:nth-child(2) { width: 12%; } /* Category */
.materials-table td:nth-child(2) { width: 12%; } /* Kategori */
.materials-table th:nth-child(3),
.materials-table td:nth-child(3) { width: 8%; } /* Unit */
.materials-table td:nth-child(3) { width: 12%; } /* Leverandør */
.materials-table th:nth-child(4),
.materials-table td:nth-child(4) { width: 10%; } /* Price */
.materials-table td:nth-child(4) { width: 10%; } /* Enhedspris */
.materials-table th:nth-child(5),
.materials-table td:nth-child(5) { width: 15%; } /* Supplier */
.materials-table td:nth-child(5) { width: 8%; } /* Enhed */
.materials-table th:nth-child(6),
.materials-table td:nth-child(6) { width: 23%; } /* Description */
.materials-table td:nth-child(6) { width: 23%; } /* Beskrivelse */
.materials-table th:nth-child(7),
.materials-table td:nth-child(7) { width: 10%; min-width: 120px; } /* Actions - sticky */
.materials-table td:nth-child(7) { width: 10%; min-width: 120px; } /* Handlinger */
/* Table Cell Styles */
/* Table Cell Styles - alle center aligned */
.material-name {
font-weight: 600;
color: #2c3e50;
word-wrap: break-word;
white-space: normal !important;
max-width: 250px;
line-height: 1.5;
padding: 8px !important;
font-size: 15px;
text-align: center !important;
}
.category-badge {
display: inline-block;
padding: 4px 8px;
background: #e3f2fd;
padding: 6px 12px;
background: linear-gradient(135deg, #e3f2fd 0%, #bbdefb 100%);
color: #1976d2;
border-radius: 12px;
border-radius: 20px;
font-size: 12px;
font-weight: 500;
}
.unit-cell {
color: #666;
font-size: 13px;
}
.price-cell {
font-weight: 600;
color: #27ae60;
font-family: 'Courier New', monospace;
text-transform: uppercase;
letter-spacing: 0.5px;
border: 1px solid #90caf9;
text-align: center !important;
}
.supplier-cell {
color: #666;
text-align: center !important;
}
.unit-cell {
text-align: center !important;
}
.description-cell {
text-align: center !important;
}
.supplier-cell {
font-weight: 600;
color: #6c757d;
text-align: center;
background: linear-gradient(135deg, #fff3e0 0%, #ffe0b2 100%);
border-radius: 8px;
padding: 8px 12px !important;
margin: 4px;
border: 1px solid #ffcc02;
}
.unit-cell {
text-align: center;
font-family: 'Courier New', monospace;
font-weight: 600;
color: #495057;
background: #f8f9fa;
border-radius: 6px;
padding: 6px 8px !important;
}
.price-cell {
text-align: center !important;
font-family: 'Courier New', monospace;
font-weight: 700;
color: #28a745;
font-size: 16px;
background: linear-gradient(135deg, #e8f5e8 0%, #d4edda 100%);
border-radius: 8px;
padding: 8px 12px !important;
border: 1px solid #c3e6cb;
}
.description-cell {
color: #666;
font-size: 13px;
max-width: 200px;
word-wrap: break-word;
white-space: normal;
line-height: 1.4;
font-size: 13px;
color: #6c757d;
max-width: 200px;
font-style: italic;
}
/* Actions */
@@ -371,24 +529,58 @@
display: flex;
gap: 8px;
justify-content: center;
text-align: center !important;
}
.edit-btn,
.delete-btn,
.save-edit-btn,
.cancel-edit-btn {
background: none;
border: none;
background: white;
border: 2px solid transparent;
cursor: pointer;
padding: 6px;
border-radius: 4px;
transition: all 0.2s;
padding: 8px 12px;
border-radius: 8px;
transition: all 0.3s ease;
font-size: 16px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
margin: 2px;
}
.edit-btn {
background: linear-gradient(135deg, #4fc3f7 0%, #29b6f6 100%);
color: white;
border-color: #29b6f6;
}
.edit-btn:hover {
background: #e3f2fd;
transform: scale(1.1);
background: linear-gradient(135deg, #29b6f6 0%, #0288d1 100%);
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(41, 182, 246, 0.3);
}
.delete-btn {
background: linear-gradient(135deg, #ef5350 0%, #f44336 100%);
color: white;
border-color: #f44336;
}
.delete-btn:hover {
background: linear-gradient(135deg, #f44336 0%, #d32f2f 100%);
transform: translateY(-2px);
box-shadow: 0 4px 8px rgba(244, 67, 54, 0.3);
}
.save-edit-btn {
background: linear-gradient(135deg, #66bb6a 0%, #4caf50 100%);
color: white;
border-color: #4caf50;
}
.cancel-edit-btn {
background: linear-gradient(135deg, #bdbdbd 0%, #9e9e9e 100%);
color: white;
border-color: #9e9e9e;
}
.delete-btn:hover {
@@ -546,3 +738,276 @@
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* Material row styling based on source */
.material-row.bygma-source {
background: linear-gradient(135deg, #fff7f0 0%, #ffffff 100%);
border-left: 3px solid #ff6b35;
}
.material-row.builtin-source {
background: linear-gradient(135deg, #f0f8f0 0%, #ffffff 100%);
border-left: 3px solid #4caf50;
}
/* Disable edit actions for Bygma products */
.material-row.bygma-source .edit-btn,
.material-row.bygma-source .delete-btn {
opacity: 0.5;
cursor: not-allowed;
pointer-events: none;
}
/* Filtreringskontroller over tabellen */
.table-filter-controls {
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
padding: 20px;
border: 1px solid #dee2e6;
border-radius: 0;
margin: 0;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
width: 100%;
box-sizing: border-box;
}
.filter-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 24px;
flex-wrap: wrap;
}
.filter-group {
display: flex;
gap: 20px;
align-items: end;
flex: 1;
}
.sort-group {
display: flex;
gap: 16px;
align-items: end;
}
.filter-group label,
.sort-group label {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 140px;
}
.filter-group label span,
.sort-group label span {
font-weight: 600;
color: #495057;
font-size: 14px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.filter-group input,
.filter-group select,
.sort-group select {
padding: 12px 16px;
border: 2px solid #e9ecef;
border-radius: 8px;
font-size: 14px;
background: white;
transition: all 0.3s ease;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
}
.filter-group input:focus,
.filter-group select:focus,
.sort-group select:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
transform: translateY(-1px);
}
.search-input {
min-width: 200px !important;
}
/* Søgecontainer med knap */
.search-container {
display: flex;
gap: 0;
border: 2px solid #e0e0e0;
border-radius: 6px;
overflow: hidden;
transition: border-color 0.2s, box-shadow 0.2s;
}
.search-container:focus-within {
border-color: #007bff;
box-shadow: 0 0 0 3px rgba(0, 123, 255, 0.1);
}
.search-container input {
border: none !important;
border-radius: 0 !important;
box-shadow: none !important;
flex: 1;
min-width: 200px;
}
.search-container input:focus {
outline: none;
border: none !important;
box-shadow: none !important;
}
.search-btn {
background: #007bff;
color: white;
border: none;
padding: 8px 12px;
cursor: pointer;
font-size: 14px;
transition: background-color 0.2s;
border-radius: 0;
}
.search-btn:hover {
background: #0056b3;
}
.search-btn:active {
background: #004085;
}
/* Responsive design for filter controls */
@media (max-width: 768px) {
.filter-row {
flex-direction: column;
align-items: stretch;
gap: 16px;
}
.filter-group,
.sort-group {
flex-direction: column;
gap: 12px;
}
.filter-group label,
.sort-group label {
min-width: auto;
}
}
/* ULTIMATE FORCE CENTER ALIGNMENT - OVERRULE EVERYTHING */
.materials-table,
.materials-table *,
.materials-table th,
.materials-table td,
.materials-table tbody,
.materials-table thead,
.materials-table tr {
text-align: center !important;
}
/* Specific overrides for all possible selectors */
table.materials-table th {
text-align: center !important;
}
table.materials-table td {
text-align: center !important;
}
.materials-table th[style*="text-align"] {
text-align: center !important;
}
.materials-table td[style*="text-align"] {
text-align: center !important;
}
/* Juster tabellen så den fylder hele bredden */
.materials-table {
border-radius: 0;
border-top: none;
width: 100% !important;
min-width: 100% !important;
}
/* EMERGENCY CENTER ALIGNMENT - SIMPLEST POSSIBLE */
table th,
table td {
text-align: center !important;
}
/* TEST - SYNLIG ÆNDRING FOR AT TJEKKE CSS INDLÆSNING */
.materials-table {
border: 5px solid red !important;
background: yellow !important;
}
/* ABSOLUT SIDSTE FORSØG - CSS HACK */
.materials-table th, .materials-table td {
text-align: center !important;
-webkit-text-align: center !important;
-moz-text-align: center !important;
-ms-text-align: center !important;
}
/* FORCE ALL ELEMENTS WITH INLINE STYLES */
[style*="text-align"] {
text-align: center !important;
}
/* ULTIMATE CENTER ALIGNMENT - REMOVE ALL CONFLICTING STYLES */
.materials-table td {
text-align: center !important;
vertical-align: middle !important;
padding: 8px !important;
}
.materials-table th {
text-align: center !important;
padding: 12px !important;
}
/* REMOVE ALL STYLING FROM ELEMENTS INSIDE FLEXBOX DIVS */
.materials-table td div span,
.materials-table td div,
.category-badge,
.supplier-cell,
.unit-cell,
.price-cell,
.description-cell,
.bygma-badge {
margin: 0 !important;
padding: 4px 8px !important;
border: none !important;
background: transparent !important;
border-radius: 0 !important;
box-shadow: none !important;
text-align: center !important;
display: inline !important;
width: auto !important;
max-width: none !important;
font-size: inherit !important;
color: inherit !important;
}
/* Only keep basic badge colors but remove positioning */
.category-badge {
background: #e3f2fd !important;
color: #1976d2 !important;
border-radius: 4px !important;
padding: 4px 8px !important;
}
.bygma-badge {
background: #ff6b35 !important;
color: white !important;
border-radius: 4px !important;
padding: 4px 8px !important;
}
+222 -117
View File
@@ -1,4 +1,4 @@
import React, { useState, useEffect, useCallback } from 'react';
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import './MaterialsList.css';
const MaterialsList = ({ apiBaseUrl }) => {
@@ -35,6 +35,26 @@ const MaterialsList = ({ apiBaseUrl }) => {
description: ''
});
const handleSortByChange = useCallback((e) => {
setSortBy(e.target.value);
}, []);
const handleSortOrderChange = useCallback((e) => {
setSortOrder(e.target.value);
}, []);
// Debounced search term for API calls
const [debouncedSearchTerm, setDebouncedSearchTerm] = useState(searchTerm);
// Debounce search term changes
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedSearchTerm(searchTerm);
}, 300); // 300ms delay
return () => clearTimeout(timer);
}, [searchTerm]);
const loadMaterials = useCallback(async (page = 1, search = '', category = '') => {
setLoading(true);
setError('');
@@ -82,23 +102,61 @@ const MaterialsList = ({ apiBaseUrl }) => {
}
}, [apiBaseUrl, itemsPerPage]);
// Helper function for pagination that uses current search/filter state
const loadPage = useCallback((page) => {
loadMaterials(page, debouncedSearchTerm, selectedCategory);
}, [loadMaterials, debouncedSearchTerm, selectedCategory]);
// Search handlers - kun til manuelle søgninger (Enter/knap)
const handleSearchSubmit = useCallback(() => {
setCurrentPage(1);
loadMaterials(1, searchTerm, selectedCategory);
}, [searchTerm, selectedCategory, loadMaterials]);
const handleSearchKeyPress = useCallback((e) => {
if (e.key === 'Enter') {
handleSearchSubmit();
}
}, [handleSearchSubmit]);
const handleSearchChange = useCallback((e) => {
setSearchTerm(e.target.value);
// Fjern automatisk søgning - kun ved Enter eller søgeknap
}, []);
const handleCategoryChange = useCallback((e) => {
setSelectedCategory(e.target.value);
setCurrentPage(1);
// Trigger søgning øjeblikkeligt for kategorifilter
loadMaterials(1, searchTerm, e.target.value);
}, [searchTerm, loadMaterials]);
const loadCategories = useCallback(async () => {
try {
const response = await fetch(`${apiBaseUrl}/api/categories`);
const response = await fetch(`${apiBaseUrl}/api/pricing/categories`);
if (!response.ok) {
throw new Error('Failed to load categories');
}
const data = await response.json();
setCategories(data || []);
if (data.success && data.data) {
// Store both name and displayName for categories
setCategories(data.data);
} else {
// Fallback to legacy format
setCategories(data || []);
}
} catch (err) {
console.error('Error loading categories:', err);
}
}, [apiBaseUrl]);
useEffect(() => {
loadMaterials(1, searchTerm, selectedCategory);
// Kun load kategorier ved første indlæsning
loadCategories();
}, [searchTerm, selectedCategory, itemsPerPage, loadMaterials, loadCategories]);
// Load materialer ved første indlæsning
loadMaterials(1, '', '');
}, [loadCategories, loadMaterials]);
const addNewMaterial = async () => {
if (!newMaterialForm.name || !newMaterialForm.unit_price) {
@@ -131,7 +189,7 @@ const MaterialsList = ({ apiBaseUrl }) => {
description: ''
});
setShowNewMaterialForm(false);
loadMaterials();
loadMaterials(currentPage, searchTerm, selectedCategory);
setError('');
} else {
setError(data.error || 'Fejl ved tilføjelse af materiale');
@@ -145,6 +203,12 @@ const MaterialsList = ({ apiBaseUrl }) => {
};
const startEditMaterial = (material) => {
// Kun tillad redigering af built-in materialer
if (material.source === 'bygma' || !material.canEdit) {
setError('Bygma produkter kan ikke redigeres');
return;
}
setEditingMaterial(material.id);
setEditMaterialForm({
name: material.name,
@@ -186,7 +250,7 @@ const MaterialsList = ({ apiBaseUrl }) => {
const data = await response.json();
if (data.success) {
setEditingMaterial(null);
loadMaterials();
loadMaterials(currentPage, searchTerm, selectedCategory);
setError('');
} else {
setError(data.error || 'Fejl ved opdatering af materiale');
@@ -200,6 +264,12 @@ const MaterialsList = ({ apiBaseUrl }) => {
};
const deleteMaterial = async (materialId, materialName) => {
// Tjek om det er et Bygma produkt
if (materialId.startsWith('bygma_')) {
setError('Bygma produkter kan ikke slettes');
return;
}
if (!window.confirm(`Er du sikker på at du vil slette "${materialName}"?`)) {
return;
}
@@ -216,7 +286,7 @@ const MaterialsList = ({ apiBaseUrl }) => {
const data = await response.json();
if (data.success) {
loadMaterials();
loadMaterials(currentPage, searchTerm, selectedCategory);
setError('');
} else {
setError(data.error || 'Fejl ved sletning af materiale');
@@ -229,31 +299,39 @@ const MaterialsList = ({ apiBaseUrl }) => {
}
};
// Sort materials (filtering now happens server-side)
const sortedMaterials = materials
.sort((a, b) => {
let aValue = a[sortBy] || a.name; // fallback to name for Bygma products
let bValue = b[sortBy] || b.name;
if (sortBy === 'unit_price') {
aValue = parseFloat(aValue) || 0;
bValue = parseFloat(bValue) || 0;
} else {
aValue = aValue.toString().toLowerCase();
bValue = bValue.toString().toLowerCase();
}
if (sortOrder === 'asc') {
return aValue > bValue ? 1 : -1;
} else {
return aValue < bValue ? 1 : -1;
}
});
// Sort materials (filtering now happens server-side) - memoized for performance
const sortedMaterials = useMemo(() => {
return materials
.sort((a, b) => {
let aValue = a[sortBy] || a.name; // fallback to name for Bygma products
let bValue = b[sortBy] || b.name;
if (sortBy === 'unit_price') {
aValue = parseFloat(aValue) || 0;
bValue = parseFloat(bValue) || 0;
} else {
aValue = aValue.toString().toLowerCase();
bValue = bValue.toString().toLowerCase();
}
if (sortOrder === 'asc') {
return aValue > bValue ? 1 : -1;
} else {
return aValue < bValue ? 1 : -1;
}
});
}, [materials, sortBy, sortOrder]);
const formatCurrency = (value) => {
return `${parseFloat(value || 0).toFixed(2)} kr`;
};
const getCategoryDisplayName = useCallback((categoryName) => {
if (!categoryName) return '-';
const category = categories.find(cat => cat.name === categoryName);
return category ? (category.displayName || category.name) : categoryName;
}, [categories]);
return (
<div className="materials-list-view">
<div className="materials-header">
@@ -261,55 +339,6 @@ const MaterialsList = ({ apiBaseUrl }) => {
<p>Administrer alle materialer og priser</p>
<div className="materials-controls">
<div className="filter-controls">
<label>
<span>Søg:</span>
<input
type="text"
placeholder="Søg i materialer..."
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value);
setCurrentPage(1); // Reset to first page when searching
}}
className="search-input"
/>
</label>
<label>
<span>Kategori:</span>
<select value={selectedCategory} onChange={(e) => {
setSelectedCategory(e.target.value);
setCurrentPage(1); // Reset to first page when filtering
}}>
<option value="">Alle kategorier</option>
{categories.map(category => (
<option key={category} value={category}>
{category}
</option>
))}
</select>
</label>
<label>
<span>Sortér efter:</span>
<select value={sortBy} onChange={(e) => setSortBy(e.target.value)}>
<option value="name">Navn</option>
<option value="category">Kategori</option>
<option value="unit_price">Pris</option>
<option value="supplier_name">Leverandør</option>
</select>
</label>
<label>
<span>Rækkefølge:</span>
<select value={sortOrder} onChange={(e) => setSortOrder(e.target.value)}>
<option value="asc">Stigende</option>
<option value="desc">Faldende</option>
</select>
</label>
</div>
<div className="action-controls">
<button
className="add-btn"
@@ -318,7 +347,7 @@ const MaterialsList = ({ apiBaseUrl }) => {
{showNewMaterialForm ? 'Annuller' : '+ Nyt Materiale'}
</button>
<button
onClick={() => loadMaterials(currentPage, searchTerm, selectedCategory)}
onClick={() => loadPage(currentPage)}
className="refresh-btn"
disabled={loading}
>
@@ -344,7 +373,9 @@ const MaterialsList = ({ apiBaseUrl }) => {
>
<option value="">Vælg kategori</option>
{categories.map(cat => (
<option key={cat} value={cat}>{cat}</option>
<option key={cat.name} value={cat.name}>
{cat.displayName || cat.name}
</option>
))}
</select>
<input
@@ -415,14 +446,14 @@ const MaterialsList = ({ apiBaseUrl }) => {
{totalPages > 1 && (
<div className="pagination-controls">
<button
onClick={() => loadMaterials(1, searchTerm, selectedCategory)}
onClick={() => loadPage(1)}
disabled={currentPage === 1 || loading}
className="page-btn"
>
« Første
</button>
<button
onClick={() => loadMaterials(currentPage - 1, searchTerm, selectedCategory)}
onClick={() => loadPage(currentPage - 1)}
disabled={currentPage === 1 || loading}
className="page-btn"
>
@@ -432,14 +463,14 @@ const MaterialsList = ({ apiBaseUrl }) => {
Side {currentPage} af {totalPages}
</span>
<button
onClick={() => loadMaterials(currentPage + 1, searchTerm, selectedCategory)}
onClick={() => loadPage(currentPage + 1)}
disabled={currentPage === totalPages || loading}
className="page-btn"
>
Næste
</button>
<button
onClick={() => loadMaterials(totalPages, searchTerm, selectedCategory)}
onClick={() => loadPage(totalPages)}
disabled={currentPage === totalPages || loading}
className="page-btn"
>
@@ -478,23 +509,84 @@ const MaterialsList = ({ apiBaseUrl }) => {
</div>
) : (
<div className="materials-table-container">
{/* Søge- og filtreringskontroller lige over tabellen */}
<div className="table-filter-controls">
<div className="filter-row">
<div className="filter-group">
<label>
<span>🔍 Søg:</span>
<div className="search-container">
<input
type="text"
placeholder="Søg i materialer... (tryk Enter)"
value={searchTerm}
onChange={handleSearchChange}
onKeyPress={handleSearchKeyPress}
className="search-input"
/>
<button
type="button"
onClick={handleSearchSubmit}
className="search-btn"
title="Søg (eller tryk Enter)"
>
🔍
</button>
</div>
</label>
<label>
<span>📂 Kategori:</span>
<select value={selectedCategory} onChange={handleCategoryChange}>
<option value="">Alle kategorier</option>
{categories.map(category => (
<option key={category.name || category} value={category.name || category}>
{category.displayName || category}
</option>
))}
</select>
</label>
</div>
<div className="sort-group">
<label>
<span>📊 Sortér:</span>
<select value={sortBy} onChange={handleSortByChange}>
<option value="name">Navn</option>
<option value="category">Kategori</option>
<option value="unit_price">Pris</option>
<option value="supplier_name">Leverandør</option>
</select>
</label>
<label>
<span>🔄 Rækkefølge:</span>
<select value={sortOrder} onChange={handleSortOrderChange}>
<option value="asc"> Stigende</option>
<option value="desc"> Faldende</option>
</select>
</label>
</div>
</div>
</div>
<table className="materials-table">
<thead>
<tr>
<th>Materiale</th>
<th>Kategori</th>
<th>Enhed</th>
<th>Pris</th>
<th>Leverandør</th>
<th>Beskrivelse</th>
<th>Handlinger</th>
<th style={{textAlign: 'center'}}>Produktnavn</th>
<th style={{textAlign: 'center'}}>Kategori</th>
<th style={{textAlign: 'center'}}>Leverandør</th>
<th style={{textAlign: 'center'}}>Enhedspris</th>
<th style={{textAlign: 'center'}}>Enhed</th>
<th style={{textAlign: 'center'}}>Beskrivelse</th>
<th style={{textAlign: 'center'}}>Handlinger</th>
</tr>
</thead>
<tbody>
{sortedMaterials.map((material) => (
<tr key={material.id} className="material-row">
{editingMaterial === material.id ? (
// Edit mode
// Edit mode - matcher CSV struktur: Produktnavn, Kategori, Leverandør, Enhedspris, Enhed, Beskrivelse, Handlinger
<>
<td>
<input
@@ -502,6 +594,7 @@ const MaterialsList = ({ apiBaseUrl }) => {
value={editMaterialForm.name}
onChange={(e) => setEditMaterialForm(prev => ({...prev, name: e.target.value}))}
className="edit-input"
placeholder="Produktnavn"
/>
</td>
<td>
@@ -512,16 +605,19 @@ const MaterialsList = ({ apiBaseUrl }) => {
>
<option value="">Vælg kategori</option>
{categories.map(cat => (
<option key={cat} value={cat}>{cat}</option>
<option key={cat.name} value={cat.name}>
{cat.displayName || cat.name}
</option>
))}
</select>
</td>
<td>
<input
type="text"
value={editMaterialForm.unit}
onChange={(e) => setEditMaterialForm(prev => ({...prev, unit: e.target.value}))}
value={editMaterialForm.supplier_name}
onChange={(e) => setEditMaterialForm(prev => ({...prev, supplier_name: e.target.value}))}
className="edit-input"
placeholder="Leverandør"
/>
</td>
<td>
@@ -530,14 +626,16 @@ const MaterialsList = ({ apiBaseUrl }) => {
value={editMaterialForm.unit_price}
onChange={(e) => setEditMaterialForm(prev => ({...prev, unit_price: e.target.value}))}
className="edit-input"
placeholder="Enhedspris"
/>
</td>
<td>
<input
type="text"
value={editMaterialForm.supplier_name}
onChange={(e) => setEditMaterialForm(prev => ({...prev, supplier_name: e.target.value}))}
value={editMaterialForm.unit}
onChange={(e) => setEditMaterialForm(prev => ({...prev, unit: e.target.value}))}
className="edit-input"
placeholder="Enhed (m², stk, kg)"
/>
</td>
<td>
@@ -567,34 +665,35 @@ const MaterialsList = ({ apiBaseUrl }) => {
</td>
</>
) : (
// View mode
// View mode - matcher CSV struktur: Produktnavn, Kategori, Leverandør, Enhedspris, Enhed, Beskrivelse, Handlinger
<>
<td className="material-name">
<td style={{textAlign: 'center', padding: '12px'}}>
{material.name || material.product_name}
</td>
<td>
<span className="category-badge">
{material.category || material.subcategory || '-'}
<td style={{textAlign: 'center', padding: '12px'}}>
<span style={{
backgroundColor: '#e3f2fd',
color: '#1976d2',
padding: '4px 8px',
borderRadius: '4px',
fontSize: '12px'
}}>
{getCategoryDisplayName(material.category || material.subcategory)}
</span>
</td>
<td className="unit-cell">
{material.unit || material.sales_unit || '-'}
</td>
<td className="price-cell">
{formatCurrency(material.unit_price || material.price)}
</td>
<td className="supplier-cell">
<td style={{textAlign: 'center', padding: '12px'}}>
{material.supplier_name || (material.source === 'bygma' ? 'Bygma' : '-')}
</td>
<td className="description-cell" title={material.description}>
{material.description ?
(material.description.length > 40 ?
material.description.substring(0, 40) + '...' :
material.description
) : '-'
}
<td style={{textAlign: 'center', padding: '12px'}}>
{formatCurrency(material.unit_price || material.price)}
</td>
<td className="actions">
<td style={{textAlign: 'center', padding: '12px'}}>
{material.unit || material.sales_unit || '-'}
</td>
<td style={{textAlign: 'center', padding: '12px'}}>
{material.description || '-'}
</td>
<td style={{textAlign: 'center', padding: '12px'}}>
{material.source !== 'bygma' && (
<>
<button
@@ -614,7 +713,13 @@ const MaterialsList = ({ apiBaseUrl }) => {
</>
)}
{material.source === 'bygma' && (
<span className="bygma-badge" title="Bygma produkt - kan ikke redigeres">
<span style={{
backgroundColor: '#ff6b35',
color: 'white',
padding: '4px 8px',
borderRadius: '4px',
fontSize: '12px'
}} title="Bygma produkt - kan ikke redigeres">
🏗 Bygma
</span>
)}
+666 -285
View File
File diff suppressed because it is too large Load Diff