Add frontend, member_state_log, etc.

This commit is contained in:
2025-07-29 14:47:03 +02:00
parent ebed331760
commit 4be0e48442
19 changed files with 5962 additions and 5 deletions
+117
View File
@@ -0,0 +1,117 @@
-- Step 1: Create the member_states lookup table
CREATE TABLE member_states (
state_code CHAR(1) PRIMARY KEY,
state_name VARCHAR(20) NOT NULL,
description TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Insert the three states
INSERT INTO member_states (state_code, state_name, description) VALUES
('A', 'Active', 'Member is currently active'),
('I', 'Inactive', 'Member is temporarily inactive'),
('D', 'Deactivated', 'Member has been permanently deactivated');
-- Step 2: Create the member_state_log table
CREATE TABLE member_state_log (
log_id SERIAL PRIMARY KEY,
member_id INTEGER NOT NULL,
state_code CHAR(1) NOT NULL,
changed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
changed_by INTEGER, -- Optional: user ID who made the change
reason TEXT, -- Optional: reason for state change
FOREIGN KEY (member_id) REFERENCES members(member_id),
FOREIGN KEY (state_code) REFERENCES member_states(state_code)
);
-- Create indexes for better performance
CREATE INDEX idx_member_state_log_member_id ON member_state_log(member_id);
CREATE INDEX idx_member_state_log_changed_at ON member_state_log(changed_at);
CREATE INDEX idx_member_state_log_state_code ON member_state_log(state_code);
-- Step 3: Migration script to populate the log table from existing data
INSERT INTO member_state_log (member_id, state_code, changed_at)
SELECT
member_id,
CASE
WHEN member_active = true THEN 'A'
ELSE 'I'
END,
COALESCE(updated_at, created_at, CURRENT_TIMESTAMP)
FROM members;
-- Step 4: Create a view to get current member states (most recent log entry per member)
CREATE OR REPLACE VIEW member_current_state AS
SELECT DISTINCT ON (msl.member_id)
msl.member_id,
msl.state_code,
ms.state_name,
ms.description,
msl.changed_at,
msl.changed_by,
msl.reason
FROM member_state_log msl
JOIN member_states ms ON msl.state_code = ms.state_code
ORDER BY msl.member_id, msl.changed_at DESC, msl.log_id DESC;
-- Step 5: Function to change member state (ensures proper logging)
CREATE OR REPLACE FUNCTION change_member_state(
p_member_id INTEGER,
p_new_state_code CHAR(1),
p_changed_by INTEGER DEFAULT NULL,
p_reason TEXT DEFAULT NULL
) RETURNS VOID AS $$
BEGIN
-- Validate that the state exists
IF NOT EXISTS (SELECT 1 FROM member_states WHERE state_code = p_new_state_code) THEN
RAISE EXCEPTION 'Invalid state code: %', p_new_state_code;
END IF;
-- Validate that the member exists
IF NOT EXISTS (SELECT 1 FROM members WHERE member_id = p_member_id) THEN
RAISE EXCEPTION 'Member not found: %', p_member_id;
END IF;
-- Insert the new state log entry
INSERT INTO member_state_log (member_id, state_code, changed_by, reason)
VALUES (p_member_id, p_new_state_code, p_changed_by, p_reason);
END;
$$ LANGUAGE plpgsql;
-- Step 6: Common queries you'll need
-- Get all active members
SELECT m.*, mcs.state_name, mcs.changed_at as last_state_change
FROM members m
JOIN member_current_state mcs ON m.member_id = mcs.member_id
WHERE mcs.state_code = 'A';
-- Get member state history
SELECT
msl.changed_at,
ms.state_name,
msl.reason,
msl.changed_by
FROM member_state_log msl
JOIN member_states ms ON msl.state_code = ms.state_code
WHERE msl.member_id = 123 -- Replace with actual member_id
ORDER BY msl.changed_at DESC;
-- Count members by state
SELECT
ms.state_name,
COUNT(*) as member_count
FROM member_current_state mcs
JOIN member_states ms ON mcs.state_code = ms.state_code
GROUP BY ms.state_code, ms.state_name
ORDER BY ms.state_code;
-- Step 7: Example usage of the change_member_state function
-- Change member 123 to inactive
SELECT change_member_state(123, 'I', 1, 'Member requested temporary suspension');
-- Reactivate member 123
SELECT change_member_state(123, 'A', 1, 'Member reactivation approved');
-- Step 8: After testing, remove the old column (CAREFUL!)
-- ALTER TABLE members DROP COLUMN member_active;
+252
View File
@@ -0,0 +1,252 @@
-- Step 1: Create the member_states lookup table
CREATE TABLE member_states (
state_code CHAR(1) PRIMARY KEY,
state_name VARCHAR(20) NOT NULL,
description TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Insert the three states
INSERT INTO member_states (state_code, state_name, description) VALUES
('A', 'Active', 'Member is currently active'),
('I', 'Inactive', 'Member is temporarily inactive'),
('D', 'Deactivated', 'Member has been permanently deactivated');
-- Step 2: Create the member_state_log table
drop table member_state_log;
CREATE TABLE member_state_log (
log_id SERIAL PRIMARY KEY,
member_id INTEGER NOT NULL,
state_code CHAR(2) NOT NULL,
changed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
changed_by INTEGER, -- Optional: user ID who made the change
reason TEXT, -- Optional: reason for state change
FOREIGN KEY (member_id) REFERENCES members(id),
FOREIGN KEY (state_code) REFERENCES member_states(state_code)
);
-- Create indexes for better performance
CREATE INDEX idx_member_state_log_member_id ON member_state_log(member_id);
CREATE INDEX idx_member_state_log_changed_at ON member_state_log(changed_at);
CREATE INDEX idx_member_state_log_state_code ON member_state_log(state_code);
-- Step 3: Migration script to populate the log table from existing data
INSERT INTO member_state_log (member_id, state_code, changed_at)
SELECT
id,
case
when member_died is not null then 'D'
when member_active = true THEN 'A'
ELSE 'I'
END,
COALESCE(member_died, updated_date, created_date, CURRENT_TIMESTAMP)
FROM members;
-- Step 4: Create a view to get current member states (most recent log entry per member)
CREATE OR REPLACE VIEW member_current_state AS
SELECT DISTINCT ON (msl.member_id)
msl.member_id,
msl.state_code,
ms.state_name,
ms.description,
msl.changed_at,
msl.changed_by,
msl.reason
FROM member_state_log msl
JOIN member_states ms ON msl.state_code = ms.state_code
ORDER BY msl.member_id, msl.changed_at DESC, msl.log_id DESC;
-- Step 5: Function to change member state (ensures proper logging)
CREATE OR REPLACE FUNCTION change_member_state(
p_member_id INTEGER,
p_new_state_code CHAR(1),
p_changed_by INTEGER DEFAULT NULL,
p_reason TEXT DEFAULT NULL
) RETURNS VOID AS $$
BEGIN
-- Validate that the state exists
IF NOT EXISTS (SELECT 1 FROM member_states WHERE state_code = p_new_state_code) THEN
RAISE EXCEPTION 'Invalid state code: %', p_new_state_code;
END IF;
-- Validate that the member exists
IF NOT EXISTS (SELECT 1 FROM members WHERE member_id = p_member_id) THEN
RAISE EXCEPTION 'Member not found: %', p_member_id;
END IF;
-- Insert the new state log entry
INSERT INTO member_state_log (member_id, state_code, changed_by, reason)
VALUES (p_member_id, p_new_state_code, p_changed_by, p_reason);
END;
$$ LANGUAGE plpgsql;
-- Step 6: Common queries you'll need
-- Get all active members
SELECT m.*, mcs.state_name, mcs.changed_at as last_state_change
FROM members m
JOIN member_current_state mcs ON m.member_id = mcs.member_id
WHERE mcs.state_code = 'A';
-- Get member state history
SELECT
msl.changed_at,
ms.state_name,
msl.reason,
msl.changed_by
FROM member_state_log msl
JOIN member_states ms ON msl.state_code = ms.state_code
WHERE msl.member_id = 123 -- Replace with actual member_id
ORDER BY msl.changed_at DESC;
-- Count members by state
SELECT
ms.state_name,
COUNT(*) as member_count
FROM member_current_state mcs
JOIN member_states ms ON mcs.state_code = ms.state_code
GROUP BY ms.state_code, ms.state_name
ORDER BY ms.state_code;
-- Step 7: Example usage of the change_member_state function
-- Change member 123 to inactive
SELECT change_member_state(123, 'I', 1, 'Member requested temporary suspension');
-- Reactivate member 123
SELECT change_member_state(123, 'A', 1, 'Member reactivation approved');
-- Step 8: Options for "calculated column" - current member state
-- OPTION 1: Add a cached state column with triggers (RECOMMENDED)
ALTER TABLE members ADD COLUMN current_state_code CHAR(1);
ALTER TABLE members ADD COLUMN current_state_changed_at TIMESTAMP;
ALTER TABLE members ADD CONSTRAINT fk_members_current_state
FOREIGN KEY (current_state_code) REFERENCES member_states(state_code);
-- Function to update member's current state cache
CREATE OR REPLACE FUNCTION update_member_current_state_cache(p_member_id INTEGER)
RETURNS VOID AS $
DECLARE
current_state RECORD;
BEGIN
-- Get the most recent state for this member
SELECT state_code, changed_at
INTO current_state
FROM member_state_log
WHERE member_id = p_member_id
ORDER BY changed_at DESC, log_id DESC
LIMIT 1;
-- Update the cached values in members table
IF FOUND THEN
UPDATE members
SET current_state_code = current_state.state_code,
current_state_changed_at = current_state.changed_at
WHERE member_id = p_member_id;
END IF;
END;
$ LANGUAGE plpgsql;
-- Trigger to automatically update cache when state changes
CREATE OR REPLACE FUNCTION trigger_update_member_state_cache()
RETURNS TRIGGER AS $
BEGIN
PERFORM update_member_current_state_cache(NEW.member_id);
RETURN NEW;
END;
$ LANGUAGE plpgsql;
CREATE TRIGGER tr_member_state_log_update_cache
AFTER INSERT ON member_state_log
FOR EACH ROW
EXECUTE FUNCTION trigger_update_member_state_cache();
-- Initialize the cache for existing members
UPDATE members
SET (current_state_code, current_state_changed_at) = (
SELECT mcs.state_code, mcs.changed_at
FROM member_current_state mcs
WHERE mcs.member_id = members.member_id
);
-- Update the change_member_state function to work with cache
CREATE OR REPLACE FUNCTION change_member_state(
p_member_id INTEGER,
p_new_state_code CHAR(1),
p_changed_by INTEGER DEFAULT NULL,
p_reason TEXT DEFAULT NULL
) RETURNS VOID AS $
BEGIN
-- Validate that the state exists
IF NOT EXISTS (SELECT 1 FROM member_states WHERE state_code = p_new_state_code) THEN
RAISE EXCEPTION 'Invalid state code: %', p_new_state_code;
END IF;
-- Validate that the member exists
IF NOT EXISTS (SELECT 1 FROM members WHERE member_id = p_member_id) THEN
RAISE EXCEPTION 'Member not found: %', p_member_id;
END IF;
-- Insert the new state log entry (trigger will update cache automatically)
INSERT INTO member_state_log (member_id, state_code, changed_by, reason)
VALUES (p_member_id, p_new_state_code, p_changed_by, p_reason);
END;
$ LANGUAGE plpgsql;
-- OPTION 2: Generated column using a function (PostgreSQL 12+)
-- Note: This approach has performance implications for large tables
ALTER TABLE members ADD COLUMN member_state CHAR(2)
GENERATED ALWAYS AS (
(SELECT state_code
FROM member_state_log msl
WHERE msl.member_id = members.id
ORDER BY changed_at DESC, log_id DESC
LIMIT 1)
) STORED;
-- OPTION 3: Materialized view (refresh manually or on schedule)
/*
CREATE MATERIALIZED VIEW members_with_current_state AS
SELECT
m.*,
mcs.state_code as current_state_code,
mcs.state_name as current_state_name,
mcs.changed_at as current_state_changed_at
FROM members m
LEFT JOIN member_current_state mcs ON m.member_id = mcs.member_id;
CREATE UNIQUE INDEX idx_members_with_current_state_id
ON members_with_current_state(member_id);
-- Refresh the materialized view (call this after state changes)
-- REFRESH MATERIALIZED VIEW members_with_current_state;
*/
-- Updated common queries using the cached column (OPTION 1)
-- Get all active members (much faster now!)
SELECT m.*, ms.state_name
FROM members m
JOIN member_states ms ON m.current_state_code = ms.state_code
WHERE m.current_state_code = 'A';
-- Get member with current state info
SELECT
m.*,
ms.state_name as current_state_name,
m.current_state_changed_at
FROM members m
LEFT JOIN member_states ms ON m.current_state_code = ms.state_code
WHERE m.member_id = 123;
-- Count members by current state (very fast)
SELECT
ms.state_name,
COUNT(*) as member_count
FROM members m
JOIN member_states ms ON m.current_state_code = ms.state_code
GROUP BY ms.state_code, ms.state_name
ORDER BY ms.state_code;
-- Step 9: After testing, remove the old column (CAREFUL!)
-- ALTER TABLE members DROP COLUMN member_active;
+255
View File
@@ -0,0 +1,255 @@
-- Step 1: Create the member_states lookup table
CREATE TABLE member_states (
state_code CHAR(1) PRIMARY KEY,
state_name VARCHAR(20) NOT NULL,
description TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Insert the three states
INSERT INTO member_states (state_code, state_name, description) VALUES
('A', 'Active', 'Member is currently active'),
('I', 'Inactive', 'Member is temporarily inactive'),
('D', 'Deactivated', 'Member has been permanently deactivated');
-- Step 2: Create the member_state_log table
CREATE TABLE member_state_log (
log_id SERIAL PRIMARY KEY,
member_id INTEGER NOT NULL,
state_code CHAR(1) NOT NULL,
changed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
changed_by INTEGER, -- Optional: user ID who made the change
reason TEXT, -- Optional: reason for state change
FOREIGN KEY (member_id) REFERENCES members(member_id),
FOREIGN KEY (state_code) REFERENCES member_states(state_code)
);
-- Create indexes for better performance
CREATE INDEX idx_member_state_log_member_id ON member_state_log(member_id);
CREATE INDEX idx_member_state_log_changed_at ON member_state_log(changed_at);
CREATE INDEX idx_member_state_log_state_code ON member_state_log(state_code);
-- Step 3: Migration script to populate the log table from existing data
INSERT INTO member_state_log (member_id, state_code, changed_at)
SELECT
member_id,
CASE
WHEN member_died IS NOT NULL THEN 'D'
WHEN member_active = true THEN 'A'
ELSE 'I'
END,
COALESCE(updated_at, created_at, CURRENT_TIMESTAMP)
FROM members;
-- Step 4: Create a view to get current member states (most recent log entry per member)
CREATE OR REPLACE VIEW member_current_state AS
SELECT DISTINCT ON (msl.member_id)
msl.member_id,
msl.state_code,
ms.state_name,
ms.description,
msl.changed_at,
msl.changed_by,
msl.reason
FROM member_state_log msl
JOIN member_states ms ON msl.state_code = ms.state_code
ORDER BY msl.member_id, msl.changed_at DESC, msl.log_id DESC;
-- Step 5: Function to change member state (ensures proper logging)
CREATE OR REPLACE FUNCTION change_member_state(
p_member_id INTEGER,
p_new_state_code CHAR(1),
p_changed_by INTEGER DEFAULT NULL,
p_reason TEXT DEFAULT NULL
) RETURNS VOID AS $$
BEGIN
-- Validate that the state exists
IF NOT EXISTS (SELECT 1 FROM member_states WHERE state_code = p_new_state_code) THEN
RAISE EXCEPTION 'Invalid state code: %', p_new_state_code;
END IF;
-- Validate that the member exists
IF NOT EXISTS (SELECT 1 FROM members WHERE member_id = p_member_id) THEN
RAISE EXCEPTION 'Member not found: %', p_member_id;
END IF;
-- Insert the new state log entry
INSERT INTO member_state_log (member_id, state_code, changed_by, reason)
VALUES (p_member_id, p_new_state_code, p_changed_by, p_reason);
END;
$$ LANGUAGE plpgsql;
-- Step 6: Common queries you'll need
-- Get all active members
SELECT m.*, mcs.state_name, mcs.changed_at as last_state_change
FROM members m
JOIN member_current_state mcs ON m.member_id = mcs.member_id
WHERE mcs.state_code = 'A';
-- Get member state history
SELECT
msl.changed_at,
ms.state_name,
msl.reason,
msl.changed_by
FROM member_state_log msl
JOIN member_states ms ON msl.state_code = ms.state_code
WHERE msl.member_id = 123 -- Replace with actual member_id
ORDER BY msl.changed_at DESC;
-- Count members by state
SELECT
ms.state_name,
COUNT(*) as member_count
FROM member_current_state mcs
JOIN member_states ms ON mcs.state_code = ms.state_code
GROUP BY ms.state_code, ms.state_name
ORDER BY ms.state_code;
-- Step 7: Example usage of the change_member_state function
-- Change member 123 to inactive
SELECT change_member_state(123, 'I', 1, 'Member requested temporary suspension');
-- Reactivate member 123
SELECT change_member_state(123, 'A', 1, 'Member reactivation approved');
-- Step 8: Options for "calculated column" - current member state
-- OPTION 1: Add a cached state column with triggers (RECOMMENDED)
ALTER TABLE members ADD COLUMN current_state_code CHAR(1);
ALTER TABLE members ADD COLUMN current_state_changed_at TIMESTAMP;
ALTER TABLE members ADD CONSTRAINT fk_members_current_state
FOREIGN KEY (current_state_code) REFERENCES member_states(state_code);
-- Function to update member's current state cache
CREATE OR REPLACE FUNCTION update_member_current_state_cache(p_member_id INTEGER)
RETURNS VOID AS $
DECLARE
current_state RECORD;
BEGIN
-- Get the most recent state for this member
SELECT state_code, changed_at
INTO current_state
FROM member_state_log
WHERE member_id = p_member_id
ORDER BY changed_at DESC, log_id DESC
LIMIT 1;
-- Update the cached values in members table
IF FOUND THEN
UPDATE members
SET current_state_code = current_state.state_code,
current_state_changed_at = current_state.changed_at
WHERE member_id = p_member_id;
END IF;
END;
$ LANGUAGE plpgsql;
-- Trigger to automatically update cache when state changes
CREATE OR REPLACE FUNCTION trigger_update_member_state_cache()
RETURNS TRIGGER AS $
BEGIN
PERFORM update_member_current_state_cache(NEW.member_id);
RETURN NEW;
END;
$ LANGUAGE plpgsql;
CREATE TRIGGER tr_member_state_log_update_cache
AFTER INSERT ON member_state_log
FOR EACH ROW
EXECUTE FUNCTION trigger_update_member_state_cache();
-- Initialize the cache for existing members
UPDATE members
SET (current_state_code, current_state_changed_at) = (
SELECT mcs.state_code, mcs.changed_at
FROM member_current_state mcs
WHERE mcs.member_id = members.member_id
);
-- Update the change_member_state function to work with cache
CREATE OR REPLACE FUNCTION change_member_state(
p_member_id INTEGER,
p_new_state_code CHAR(1),
p_changed_by INTEGER DEFAULT NULL,
p_reason TEXT DEFAULT NULL
) RETURNS VOID AS $
BEGIN
-- Validate that the state exists
IF NOT EXISTS (SELECT 1 FROM member_states WHERE state_code = p_new_state_code) THEN
RAISE EXCEPTION 'Invalid state code: %', p_new_state_code;
END IF;
-- Validate that the member exists
IF NOT EXISTS (SELECT 1 FROM members WHERE member_id = p_member_id) THEN
RAISE EXCEPTION 'Member not found: %', p_member_id;
END IF;
-- Insert the new state log entry (trigger will update cache automatically)
INSERT INTO member_state_log (member_id, state_code, changed_by, reason)
VALUES (p_member_id, p_new_state_code, p_changed_by, p_reason);
END;
$ LANGUAGE plpgsql;
-- OPTION 2: Generated column using a function (NOT POSSIBLE)
-- PostgreSQL doesn't allow subqueries in generated column expressions
-- This approach won't work:
/*
ALTER TABLE members ADD COLUMN current_state_code_generated CHAR(1)
GENERATED ALWAYS AS (
(SELECT state_code
FROM member_state_log msl
WHERE msl.member_id = members.member_id
ORDER BY changed_at DESC, log_id DESC
LIMIT 1)
) STORED;
-- ERROR: cannot use subquery in column generation expression
*/
-- OPTION 3: Materialized view (refresh manually or on schedule)
/*
CREATE MATERIALIZED VIEW members_with_current_state AS
SELECT
m.*,
mcs.state_code as current_state_code,
mcs.state_name as current_state_name,
mcs.changed_at as current_state_changed_at
FROM members m
LEFT JOIN member_current_state mcs ON m.member_id = mcs.member_id;
CREATE UNIQUE INDEX idx_members_with_current_state_id
ON members_with_current_state(member_id);
-- Refresh the materialized view (call this after state changes)
-- REFRESH MATERIALIZED VIEW members_with_current_state;
*/
-- Updated common queries using the cached column (OPTION 1)
-- Get all active members (much faster now!)
SELECT m.*, ms.state_name
FROM members m
JOIN member_states ms ON m.current_state_code = ms.state_code
WHERE m.current_state_code = 'A';
-- Get member with current state info
SELECT
m.*,
ms.state_name as current_state_name,
m.current_state_changed_at
FROM members m
LEFT JOIN member_states ms ON m.current_state_code = ms.state_code
WHERE m.member_id = 123;
-- Count members by current state (very fast)
SELECT
ms.state_name,
COUNT(*) as member_count
FROM members m
JOIN member_states ms ON m.current_state_code = ms.state_code
GROUP BY ms.state_code, ms.state_name
ORDER BY ms.state_code;
-- Step 9: After testing, remove the old column (CAREFUL!)
-- ALTER TABLE members DROP COLUMN member_active;
+197
View File
@@ -0,0 +1,197 @@
from sqlalchemy import Column, String, Integer, DateTime, Text, ForeignKey
from sqlalchemy.orm import relationship
from sqlalchemy.ext.declarative import declarative_base
from datetime import datetime
Base = declarative_base()
# OPTION 1: Correct relationship setup
class MemberStates(Base):
__tablename__ = 'member_states'
state_code = Column(String(1), primary_key=True)
state_name = Column(String(20), nullable=False)
description = Column(Text)
created_at = Column(DateTime, default=datetime.utcnow)
# Backref will create 'members' attribute on MemberStates
# members = relationship('Members', back_populates='current_state')
class Members(Base):
__tablename__ = 'members'
id = Column(Integer, primary_key=True) # Assuming 'id' is your primary key
# ... other member fields ...
# Add foreign key constraint
current_state_code = Column(String(1), ForeignKey('member_states.state_code'), index=True)
current_state_changed_at = Column(DateTime)
# Correct relationship definition
current_state = relationship('MemberStates', back_populates='members')
# Update MemberStates to complete the bidirectional relationship
MemberStates.members = relationship('Members', back_populates='current_state')
class MemberStateLog(Base):
__tablename__ = 'member_state_log'
log_id = Column(Integer, primary_key=True)
member_id = Column(Integer, ForeignKey('members.id'), nullable=False)
state_code = Column(String(1), ForeignKey('member_states.state_code'), nullable=False)
changed_at = Column(DateTime, default=datetime.utcnow)
changed_by = Column(Integer) # Optional: user ID who made the change
reason = Column(Text)
# Relationships
member = relationship('Members', backref='state_history')
state = relationship('MemberStates')
# OPTION 2: Alternative approach with primaryjoin (if you can't add foreign key)
class MembersAlternative(Base):
__tablename__ = 'members'
id = Column(Integer, primary_key=True)
current_state_code = Column(String(1), index=True) # No foreign key
current_state_changed_at = Column(DateTime)
# Use primaryjoin to specify the join condition
current_state = relationship(
'MemberStates',
primaryjoin='Members.current_state_code == MemberStates.state_code',
foreign_keys=[current_state_code]
)
# USAGE EXAMPLES:
# Example 1: Query members with their current state
def get_members_with_state():
from sqlalchemy.orm import sessionmaker
Session = sessionmaker()
session = Session()
members = session.query(Members).join(Members.current_state).all()
for member in members:
print(f"Member: {member.name}")
print(f"State: {member.current_state.state_name}")
print(f"Description: {member.current_state.description}")
session.close()
# Example 2: Get member state name directly
def get_member_state_name(member):
if member.current_state:
return member.current_state.state_name
return "Unknown"
# Example 3: Filter members by state
def get_active_members():
from sqlalchemy.orm import sessionmaker
Session = sessionmaker()
session = Session()
active_members = session.query(Members).filter(
Members.current_state_code == 'A'
).all()
session.close()
return active_members
# Example 4: Count members by state
def count_members_by_state():
from sqlalchemy.orm import sessionmaker
from sqlalchemy import func
Session = sessionmaker()
session = Session()
counts = session.query(
MemberStates.state_name,
func.count(Members.id).label('count')
).outerjoin(Members).group_by(
MemberStates.state_code,
MemberStates.state_name
).all()
session.close()
return counts
# Example 5: Update member state (with logging)
def change_member_state(session, member_id, new_state_code, changed_by=None, reason=None):
# Get the member
member = session.query(Members).get(member_id)
if not member:
raise ValueError(f"Member {member_id} not found")
# Validate state exists
state = session.query(MemberStates).get(new_state_code)
if not state:
raise ValueError(f"Invalid state code: {new_state_code}")
# Log the state change
log_entry = MemberStateLog(
member_id=member_id,
state_code=new_state_code,
changed_by=changed_by,
reason=reason
)
session.add(log_entry)
# Update member's current state
member.current_state_code = new_state_code
member.current_state_changed_at = datetime.utcnow()
session.commit()
# Example 6: Serialization for API responses
def serialize_member_with_state(member):
return {
'id': member.id,
'name': member.name, # Assuming you have a name field
'current_state_code': member.current_state_code,
'current_state_name': member.current_state.state_name if member.current_state else None,
'current_state_description': member.current_state.description if member.current_state else None,
'current_state_changed_at': member.current_state_changed_at.isoformat() if member.current_state_changed_at else None
}
# Example 7: Query with eager loading to avoid N+1 queries
def get_all_members_with_states_optimized():
from sqlalchemy.orm import sessionmaker, joinedload
Session = sessionmaker()
session = Session()
members = session.query(Members).options(
joinedload(Members.current_state)
).all()
# Now you can access member.current_state.state_name without additional queries
result = [serialize_member_with_state(member) for member in members]
session.close()
return result
# Example 8: Custom property for easier access
class MembersWithProperty(Base):
__tablename__ = 'members'
id = Column(Integer, primary_key=True)
current_state_code = Column(String(1), ForeignKey('member_states.state_code'), index=True)
current_state_changed_at = Column(DateTime)
current_state = relationship('MemberStates')
@property
def state_name(self):
return self.current_state.state_name if self.current_state else "Unknown"
@property
def is_active(self):
return self.current_state_code == 'A'
@property
def is_inactive(self):
return self.current_state_code == 'I'
@property
def is_deactivated(self):
return self.current_state_code == 'D'
+1061
View File
File diff suppressed because it is too large Load Diff
+10
View File
@@ -0,0 +1,10 @@
{
"dependencies": {
"@types/react": "^19.1.8",
"lucide-react": "^0.525.0",
"react": "^19.1.0"
},
"devDependencies": {
"vite": "^7.0.6"
}
}
+60 -2
View File
@@ -1,4 +1,4 @@
from fastapi import FastAPI, HTTPException, Depends from fastapi import FastAPI, HTTPException, Depends, WebSocket
from fastapi.security import OAuth2PasswordBearer from fastapi.security import OAuth2PasswordBearer
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel from pydantic import BaseModel
@@ -7,6 +7,9 @@ from sustApp.models.models import Base, Members, Payments
from sustApp.models.database import engine, SessionLocal from sustApp.models.database import engine, SessionLocal
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from datetime import datetime from datetime import datetime
import asyncio
import logging
#http://127.0.0.1:8000/docs -> swagger like ui #http://127.0.0.1:8000/docs -> swagger like ui
#ngrok http http://localhost:8000 #ngrok http http://localhost:8000
@@ -27,6 +30,7 @@ origins = [
"http://localhost", "http://localhost",
"http://localhost:8080", "http://localhost:8080",
"http://localhost:3000", "http://localhost:3000",
"http://localhost:5173",
] ]
app.add_middleware( app.add_middleware(
@@ -37,6 +41,48 @@ app.add_middleware(
allow_headers=["*"], allow_headers=["*"],
) )
# Store active WebSocket connections
connected_clients = []
# Custom logging handler to send logs via WebSocket
class WebSocketHandler(logging.Handler):
def emit(self, record):
log_message = self.format(record)
# Send to all connected clients
asyncio.create_task(broadcast_log(log_message))
# Set up logging
logger = logging.getLogger("app")
logger.setLevel(logging.INFO)
ws_handler = WebSocketHandler()
logger.addHandler(ws_handler)
@app.websocket("/ws/logs")
async def websocket_endpoint(websocket: WebSocket):
await websocket.accept()
connected_clients.append(websocket)
try:
while True:
await websocket.receive_text()
except:
connected_clients.remove(websocket)
async def broadcast_log(message):
for client in connected_clients[:]: # Copy list to avoid modification during iteration
try:
await client.send_json({
"type": "log",
"message": message,
"timestamp": asyncio.get_event_loop().time()
})
except:
connected_clients.remove(client)
@app.get("/test")
async def test_endpoint():
logger.info("This is a test log message from backend!")
return {"message": "Check your browser console"}
class User(BaseModel): class User(BaseModel):
username: str username: str
email: str | None = None email: str | None = None
@@ -69,6 +115,8 @@ class MemberBase(BaseModel):
#payments: Optional[List[PaymentBase]] #payments: Optional[List[PaymentBase]]
member_deliveries: Optional[List[DeliveryBase]] member_deliveries: Optional[List[DeliveryBase]]
#deliveries: Mapped[List["DeliveryBase"]] = relationship() #deliveries: Mapped[List["DeliveryBase"]] = relationship()
current_state_code: str | None = None
current_state_name: str | None = None
def get_db(): def get_db():
db = SessionLocal() db = SessionLocal()
@@ -86,11 +134,21 @@ async def read_members(db: db_dependency, skip: int = 0, limit: int = 100 ):
raise HTTPException(status_code=400, detail='No members') raise HTTPException(status_code=400, detail='No members')
return members return members
@app.get("/activememberscount/", response_model=int)
async def read_activememberscount(db: db_dependency):
count = db.query(Members).filter(Members.member_active == True).count()
logger.info("This is a test log message from backend!")
return count
@app.get("/member/{member_id}") @app.get("/member/{member_id}")
async def read_member(member_id: int, db: db_dependency): #, token: Annotated[str, Depends(oauth2_scheme)]): async def read_member(member_id: int, db: db_dependency): #, token: Annotated[str, Depends(oauth2_scheme)]):
result = db.query(Members).filter(Members.id == member_id).first() result = db.query(Members).join(Members.current_state).filter(Members.id == member_id).first()
if not result: if not result:
raise HTTPException(status_code=400, detail='Member does not exist') raise HTTPException(status_code=400, detail='Member does not exist')
print(f"Member: {result.member_surname}")
print(f"State: {result.current_state.state_name}")
print(f"Description: {result.current_state.description}")
return result return result
@app.get("/payments/{member_id}") @app.get("/payments/{member_id}")
+46 -2
View File
@@ -1,11 +1,21 @@
from sqlalchemy import Boolean, Column, ForeignKey, Integer, String, DateTime, Float, Numeric from sqlalchemy import Boolean, Column, ForeignKey, Integer, String, DateTime, Float, Numeric
from sqlalchemy.types import TypeDecorator, VARCHAR, Integer from sqlalchemy.types import TypeDecorator, VARCHAR, Integer, CHAR
from sqlalchemy_utils import EmailType from sqlalchemy_utils import EmailType
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from models.database import Base #from models.database import Base
from sustApp.models.database import Base
import datetime import datetime
class TrimChar(TypeDecorator):
impl = CHAR
cache_key = False
def process_result_value(self, value, dialect):
if value:
value = value.strip()
return value
class NullCatchingString(TypeDecorator): class NullCatchingString(TypeDecorator):
impl = VARCHAR impl = VARCHAR
@@ -50,6 +60,18 @@ class Members(Base):
member_deliveries = relationship('Deliveries', backref='members') member_deliveries = relationship('Deliveries', backref='members')
member_payments = relationship('Payments', backref='members') member_payments = relationship('Payments', backref='members')
#member_deliveries: Mapped[List["Deliveries"]] = relationship() #member_deliveries: Mapped[List["Deliveries"]] = relationship()
#current_state_code = Column(String, index=True)
#current_state_name = relationship('MemberStates', backref='state_code')
current_state_code = Column(TrimChar, index=True) # No foreign key
current_state_changed_at = Column(DateTime)
# Use primaryjoin to specify the join condition
current_state = relationship(
'MemberStates',
primaryjoin='Members.current_state_code == MemberStates.state_code',
foreign_keys=[current_state_code]
)
def __repr__(self): def __repr__(self):
return f'<Comment "{self.content[:20]}...">' return f'<Comment "{self.content[:20]}...">'
@@ -96,3 +118,25 @@ class Payments_CSV(Base):
pc_currency = Column(String) pc_currency = Column(String)
pc_timestamp = Column(DateTime) pc_timestamp = Column(DateTime)
pc_processed = Column(Boolean, default=False) pc_processed = Column(Boolean, default=False)
class MemberStates(Base):
__tablename__ = 'member_states'
state_code = Column(TrimChar, primary_key=True, index=True)
state_name = Column(String(20), nullable=False)
description = Column(String)
created_at = Column(DateTime)
class MemberStateLog(Base):
__tablename__ = 'member_state_log'
log_id = Column(Integer, primary_key=True)
member_id = Column(Integer, ForeignKey('members.id'), nullable=False)
state_code = Column(String(1), ForeignKey('member_states.state_code'), nullable=False)
changed_at = Column(DateTime)
changed_by = Column(Integer) # Optional: user ID who made the change
reason = Column(String)
# Relationships
member = relationship('Members', backref='state_history')
state = relationship('MemberStates')
+14
View File
@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>FastAPI Dashboard</title>
<link href="./dist/output.css" rel="stylesheet" />
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/index.tsx"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+30
View File
@@ -0,0 +1,30 @@
{
"name": "react-frontend",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"serve": "vite preview"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "commonjs",
"dependencies": {
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"lucide-react": "^0.525.0",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"typescript": "^5.8.3"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.7.0",
"autoprefixer": "^10.4.21",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.11",
"vite": "^7.0.6"
}
}
@@ -0,0 +1,748 @@
import React, { useState, useEffect } from 'react';
import {
Server,
Database,
Users,
Activity,
RefreshCw,
Plus,
Edit3,
Trash2,
Eye,
AlertCircle,
CheckCircle,
XCircle
} from 'lucide-react';
import { useEffect } from 'react';
function App() {
useEffect(() => {
const ws = new WebSocket('ws://localhost:8000/ws/logs');
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.type === 'log') {
console.log(`[Backend Log]: ${data.message}`);
}
};
ws.onclose = () => {
console.log('WebSocket connection closed');
};
return () => {
ws.close();
};
}, []);
return <div>Your React App</div>;
}
const Dashboard = () => {
const [apiUrl, setApiUrl] = useState('http://127.0.0.1:8000');
const [connectionStatus, setConnectionStatus] = useState('disconnected');
const [isLoading, setIsLoading] = useState(false);
const [activeTab, setActiveTab] = useState('dashboard');
const [members, setMembers] = useState([]);
const [totalMembers, setTotalMembers] = useState(0);
const [loadingData, setLoadingData] = useState(false);
const testConnection = async () => {
setIsLoading(true);
try {
console.log('Testing connection to:', apiUrl);
// Test with a simple endpoint first - try /member/1 since /member/100 works
const response = await fetch(`${apiUrl}/member/100`);
if (response.ok) {
setConnectionStatus('connected');
console.log('Connection successful!');
// Load members data when connection is successful
await loadMembersCount();
} else {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
} catch (error) {
console.error('Connection error:', error);
setConnectionStatus('error');
}
setIsLoading(false);
};
const loadMembersCount = async () => {
if (connectionStatus !== 'connected' && !isLoading) return;
setLoadingData(true);
console.log('Attempting to load active members count...');
// const fetchActiveMembersCount = async () => {
try {
const response = await fetch(`${apiUrl}/activememberscount`);
if (response.ok) {
const count = await response.json(); // Single number return
setTotalMembers(count);
console.log('Total active members count:', count);
} else {
console.log('Failed to fetch active members count:', response.statusText);
setTotalMembers(0);
}
} catch (error) {
console.log('Error fetching active members count:', error);
setMembers([]);
setTotalMembers(0);
}
//}
setLoadingData(false);
};
const loadMembers = async () => {
if (connectionStatus !== 'connected' && !isLoading) return;
setLoadingData(true);
try {
// Since /members/ gives 500 error, let's try different approaches
console.log('Attempting to load active members count...');
// Option 1: Try individual member requests (if you know IDs)
// For now, let's try to get a few members by ID
const memberIds = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; // Adjust these IDs as needed
const memberPromises = memberIds.map(async (id) => {
try {
const response = await fetch(`${apiUrl}/member/${id}`);
if (response.ok) {
return await response.json();
}
return null;
} catch (error) {
console.log(`Member ${id} not found or error:`, error);
return null;
}
});
const results = await Promise.all(memberPromises);
const validMembers = results.filter(member => member !== null);
console.log('Found members:', validMembers);
setMembers(validMembers);
setTotalMembers(validMembers.length);
if (validMembers.length === 0) {
console.warn('No members found with IDs 1-10. You may need to adjust the ID range or fix the /members/ endpoint.');
}
} catch (error) {
console.error('Error loading members:', error);
setMembers([]);
setTotalMembers(0);
}
setLoadingData(false);
};
// Load members when component mounts if already connected
useEffect(() => {
if (connectionStatus === 'connected') {
loadMembersCount();
}
}, [connectionStatus]);
const getStatusColor = () => {
switch (connectionStatus) {
case 'connected': return '#10b981';
case 'error': return '#ef4444';
default: return '#f59e0b';
}
};
const getStatusIcon = () => {
switch (connectionStatus) {
case 'connected': return <CheckCircle size={16} />;
case 'error': return <XCircle size={16} />;
default: return <AlertCircle size={16} />;
}
};
const getStatusText = () => {
switch (connectionStatus) {
case 'connected': return 'Connected';
case 'error': return 'Connection Error';
default: return 'Disconnected';
}
};
const getFullName = (member) => {
const firstName = member.member_firstname || '';
const lastName = member.member_surname || '';
const memberSuffix = member.member_suffix || '';
const fullName = `${firstName} ${lastName} ${memberSuffix}`.trim();
return fullName || 'N/A';
};
const MEMBER_STATE_COLORS = {
'A': '#10B981', // Green - Active
'I': '#F59E0B', // Amber/Orange - Inactive
'D': '#EF4444' // Red - Deactivated
};
const getMemberStateColor = (stateCode) => {
return MEMBER_STATE_COLORS[stateCode] || '#6B7280'; // Gray as fallback
};
const styles = {
container: {
minHeight: '100vh',
backgroundColor: '#f9fafb',
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif'
},
header: {
backgroundColor: 'white',
boxShadow: '0 1px 3px 0 rgba(0, 0, 0, 0.1)',
borderBottom: '1px solid #e5e7eb'
},
headerContent: {
maxWidth: '1280px',
margin: '0 auto',
padding: '0 24px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
paddingTop: '24px',
paddingBottom: '24px'
},
title: {
fontSize: '30px',
fontWeight: 'bold',
color: '#111827',
margin: 0
},
subtitle: {
color: '#6b7280',
marginTop: '4px',
fontSize: '16px'
},
statusContainer: {
display: 'flex',
alignItems: 'center',
gap: '8px',
color: getStatusColor(),
fontWeight: '500'
},
mainContent: {
maxWidth: '1280px',
margin: '0 auto',
padding: '32px 24px'
},
card: {
backgroundColor: 'white',
borderRadius: '8px',
boxShadow: '0 1px 3px 0 rgba(0, 0, 0, 0.1)',
border: '1px solid #e5e7eb',
padding: '24px',
marginBottom: '32px'
},
cardTitle: {
fontSize: '18px',
fontWeight: '600',
color: '#111827',
marginBottom: '16px'
},
connectionForm: {
display: 'flex',
alignItems: 'center',
gap: '16px'
},
input: {
flex: 1,
padding: '8px 12px',
border: '1px solid #d1d5db',
borderRadius: '6px',
fontSize: '14px',
outline: 'none'
},
button: {
display: 'inline-flex',
alignItems: 'center',
gap: '8px',
padding: '8px 16px',
backgroundColor: '#2563eb',
color: 'white',
border: 'none',
borderRadius: '6px',
fontSize: '14px',
fontWeight: '500',
cursor: 'pointer',
transition: 'background-color 0.2s'
},
buttonDisabled: {
opacity: 0.5,
cursor: 'not-allowed'
},
errorAlert: {
marginTop: '16px',
padding: '16px',
backgroundColor: '#fef2f2',
border: '1px solid #fecaca',
borderRadius: '6px',
display: 'flex',
alignItems: 'center',
gap: '8px'
},
errorText: {
color: '#991b1b'
},
tabs: {
borderBottom: '1px solid #e5e7eb',
marginBottom: '32px'
},
tabsNav: {
display: 'flex',
gap: '32px'
},
tab: {
padding: '8px 4px',
fontSize: '14px',
fontWeight: '500',
border: 'none',
background: 'none',
cursor: 'pointer',
borderBottom: '2px solid transparent',
transition: 'all 0.2s'
},
tabActive: {
borderBottom: '2px solid #2563eb',
color: '#2563eb'
},
tabInactive: {
color: '#6b7280'
},
statsGrid: {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))',
gap: '24px',
marginBottom: '32px'
},
statCard: {
backgroundColor: 'white',
borderRadius: '8px',
boxShadow: '0 1px 3px 0 rgba(0, 0, 0, 0.1)',
border: '1px solid #e5e7eb',
padding: '24px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between'
},
statContent: {
flex: 1
},
statLabel: {
fontSize: '14px',
fontWeight: '500',
color: '#6b7280'
},
statValue: {
fontSize: '24px',
fontWeight: 'bold',
color: '#111827',
marginTop: '8px'
},
statIcon: {
padding: '12px',
borderRadius: '50%'
},
iconOrange: {
backgroundColor: '#fed7aa',
color: '#ea580c'
},
iconBlue: {
backgroundColor: '#dbeafe',
color: '#2563eb'
},
iconGreen: {
backgroundColor: '#dcfce7',
color: '#16a34a'
},
iconPurple: {
backgroundColor: '#e9d5ff',
color: '#9333ea'
},
actionButtons: {
display: 'flex',
flexWrap: 'wrap',
gap: '16px'
},
actionButton: {
display: 'inline-flex',
alignItems: 'center',
gap: '8px',
padding: '8px 16px',
border: 'none',
borderRadius: '6px',
fontSize: '14px',
fontWeight: '500',
cursor: 'pointer',
transition: 'background-color 0.2s'
},
actionButtonGray: {
backgroundColor: '#f3f4f6',
color: '#374151'
},
actionButtonGreen: {
backgroundColor: '#dcfce7',
color: '#166534'
},
actionButtonBlue: {
backgroundColor: '#dbeafe',
color: '#1e40af'
},
emptyState: {
textAlign: 'center',
padding: '32px'
},
emptyStateIcon: {
margin: '0 auto 16px',
color: '#9ca3af'
},
emptyStateText: {
color: '#6b7280'
},
cardHeader: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '20px'
},
refreshButton: {
display: 'flex',
alignItems: 'center',
padding: '8px 16px',
backgroundColor: '#3B82F6',
color: 'white',
border: 'none',
borderRadius: '6px',
cursor: 'pointer',
fontSize: '14px',
fontWeight: '500'
},
loadingState: {
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
padding: '40px',
color: '#6B7280'
},
loadingIcon: {
marginBottom: '10px',
color: '#3B82F6'
},
dataContainer: {
marginTop: '20px'
},
dataHeader: {
marginBottom: '16px',
padding: '12px',
backgroundColor: '#F3F4F6',
borderRadius: '6px'
},
dataCount: {
margin: 0,
fontWeight: '600',
color: '#374151'
},
tableContainer: {
overflowX: 'auto',
border: '1px solid #E5E7EB',
borderRadius: '8px'
},
table: {
width: '100%',
borderCollapse: 'collapse'
},
tableHeaderRow: {
backgroundColor: '#F9FAFB'
},
tableHeader: {
padding: '12px',
textAlign: 'left',
fontWeight: '600',
color: '#374151',
borderBottom: '1px solid #E5E7EB'
},
tableRow: {
borderBottom: '1px solid #F3F4F6'
},
tableCell: {
padding: '12px',
color: '#6B7280'
},
statusBadge: {
padding: '4px 8px',
borderRadius: '12px',
fontSize: '12px',
fontWeight: '500'
},
};
return (
<div style={styles.container}>
{/* Header */}
<div style={styles.header}>
<div style={styles.headerContent}>
<div>
<h1 style={styles.title}>FastAPI Dashboard</h1>
<p style={styles.subtitle}>Manage your backend data and monitor API status</p>
</div>
<div style={styles.statusContainer}>
{getStatusIcon()}
<span>{getStatusText()}</span>
</div>
</div>
</div>
{/* Main Content */}
<div style={styles.mainContent}>
{/* Connection Section */}
<div style={styles.card}>
<h2 style={styles.cardTitle}>API Connection</h2>
<div style={styles.connectionForm}>
<input
type="text"
value={apiUrl}
onChange={(e) => setApiUrl(e.target.value)}
style={styles.input}
placeholder="Enter API URL"
/>
<button
onClick={testConnection}
disabled={isLoading}
style={{
...styles.button,
...(isLoading ? styles.buttonDisabled : {}),
':hover': { backgroundColor: '#1d4ed8' }
}}
onMouseEnter={(e) => {
if (!isLoading) e.target.style.backgroundColor = '#1d4ed8';
}}
onMouseLeave={(e) => {
if (!isLoading) e.target.style.backgroundColor = '#2563eb';
}}
>
<RefreshCw size={16} style={isLoading ? { animation: 'spin 1s linear infinite' } : {}} />
Test Connection
</button>
</div>
{connectionStatus === 'error' && (
<div style={styles.errorAlert}>
<XCircle size={20} style={{ color: '#dc2626' }} />
<span style={styles.errorText}>NetworkError when attempting to fetch resource.</span>
</div>
)}
</div>
{/* Navigation Tabs */}
<div style={styles.tabs}>
<div style={styles.tabsNav}>
<button
onClick={() => setActiveTab('dashboard')}
style={{
...styles.tab,
...(activeTab === 'dashboard' ? styles.tabActive : styles.tabInactive)
}}
>
Dashboard
</button>
<button
onClick={() => setActiveTab('data')}
style={{
...styles.tab,
...(activeTab === 'data' ? styles.tabActive : styles.tabInactive)
}}
>
Data
</button>
</div>
</div>
{/* Stats Grid */}
<div style={styles.statsGrid}>
{/* API Status */}
<div style={styles.statCard}>
<div style={styles.statContent}>
<p style={styles.statLabel}>API Status</p>
<p style={styles.statValue}>
{connectionStatus === 'connected' ? 'Online' : 'Offline'}
</p>
</div>
<div style={{...styles.statIcon, ...styles.iconOrange}}>
<Server size={24} />
</div>
</div>
{/* Active Members */}
<div style={styles.statCard}>
<div style={styles.statContent}>
<p style={styles.statLabel}>Active Members</p>
<p style={styles.statValue}>{totalMembers}</p>
</div>
<div style={{...styles.statIcon, ...styles.iconBlue}}>
<Database size={24} />
</div>
</div>
{/* Active Users */}
<div style={styles.statCard}>
<div style={styles.statContent}>
<p style={styles.statLabel}>Active Users</p>
<p style={styles.statValue}>1</p>
</div>
<div style={{...styles.statIcon, ...styles.iconGreen}}>
<Users size={24} />
</div>
</div>
{/* Activity */}
<div style={styles.statCard}>
<div style={styles.statContent}>
<p style={styles.statLabel}>Activity</p>
<p style={styles.statValue}>Live</p>
</div>
<div style={{...styles.statIcon, ...styles.iconPurple}}>
<Activity size={24} />
</div>
</div>
</div>
{/* Quick Actions */}
<div style={styles.card}>
<h2 style={styles.cardTitle}>Quick Actions</h2>
<div style={styles.actionButtons}>
<button
style={{...styles.actionButton, ...styles.actionButtonGray}}
onMouseEnter={(e) => e.target.style.backgroundColor = '#e5e7eb'}
onMouseLeave={(e) => e.target.style.backgroundColor = '#f3f4f6'}
>
<Eye size={16} />
View Data
</button>
<button
style={{...styles.actionButton, ...styles.actionButtonGreen}}
onMouseEnter={(e) => e.target.style.backgroundColor = '#bbf7d0'}
onMouseLeave={(e) => e.target.style.backgroundColor = '#dcfce7'}
>
<Plus size={16} />
Add Item
</button>
<button
style={{...styles.actionButton, ...styles.actionButtonBlue}}
onMouseEnter={(e) => e.target.style.backgroundColor = '#bfdbfe'}
onMouseLeave={(e) => e.target.style.backgroundColor = '#dbeafe'}
>
<RefreshCw size={16} />
Refresh
</button>
</div>
</div>
{/* Content based on active tab */}
{activeTab === 'data' && (
<div style={styles.card}>
<div style={styles.cardHeader}>
<h2 style={styles.cardTitle}>Data Management</h2>
<button
onClick={loadMembers}
style={styles.refreshButton}
disabled={isLoading}
>
<RefreshCw size={16} style={{ marginRight: '8px' }} />
{isLoading ? 'Loading...' : 'Load Members'}
</button>
</div>
{isLoading ? (
<div style={styles.loadingState}>
<RefreshCw size={24} style={{ ...styles.loadingIcon, animation: 'spin 1s linear infinite' }} />
<p>Loading members...</p>
</div>
) : members.length === 0 ? (
<div style={styles.emptyState}>
<Database size={48} style={styles.emptyStateIcon} />
<p style={styles.emptyStateText}>No members found. Click "Load Members" to fetch data.</p>
</div>
) : (
<div style={styles.dataContainer}>
<div style={styles.dataHeader}>
<p style={styles.dataCount}>Total Members: {members.length}</p>
</div>
<div style={styles.tableContainer}>
<table style={styles.table}>
<thead>
<tr style={styles.tableHeaderRow}>
<th style={styles.tableHeader}>ID</th>
<th style={styles.tableHeader}>Name</th>
<th style={styles.tableHeader}>Email</th>
<th style={styles.tableHeader}>Active</th>
<th style={styles.tableHeader}>Actions</th>
</tr>
</thead>
<tbody>
{members.map((member, index) => (
<tr key={member.id || index} style={styles.tableRow}>
<td style={styles.tableCell}>{member.id}</td>
<td style={styles.tableCell}>{getFullName(member)}</td>
<td style={styles.tableCell}>{member.member_email || 'N/A'}</td>
<td style={styles.tableCell}>
<span style={{
...styles.statusBadge,
backgroundColor: getMemberStateColor(member.current_state.state_code),
color: 'white'
}}>
{member.current_state.state_name}
</span>
</td>
<td style={styles.tableCell}>
<button style={styles.actionButton}>
<Edit3 size={14} />
</button>
<button style={styles.actionButton}>
<Trash2 size={14} />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
)}
</div>
<style>
{`
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
`}
</style>
</div>
);
};
export default Dashboard;
@@ -0,0 +1,458 @@
import React, { useState, useEffect } from 'react';
import { Search, Plus, Edit3, Trash2, RefreshCw, AlertCircle, CheckCircle, Server, Database, Users, Activity } from 'lucide-react';
const FastAPIFrontend = () => {
const [apiUrl, setApiUrl] = useState('http://localhost:8000');
const [isConnected, setIsConnected] = useState(false);
const [loading, setLoading] = useState(false);
const [data, setData] = useState([]);
const [error, setError] = useState(null);
const [activeTab, setActiveTab] = useState('dashboard');
const [formData, setFormData] = useState({});
const [showModal, setShowModal] = useState(false);
const [editingItem, setEditingItem] = useState(null);
// Test API connection
const testConnection = async () => {
setLoading(true);
setError(null);
try {
const response = await fetch(`${apiUrl}/health`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
});
if (response.ok) {
setIsConnected(true);
setError(null);
} else {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
} catch (err) {
setIsConnected(false);
setError(err.message || 'Connection failed');
} finally {
setLoading(false);
}
};
// Fetch data from API
const fetchData = async (endpoint = 'items') => {
if (!isConnected) return;
setLoading(true);
try {
const response = await fetch(`${apiUrl}/${endpoint}`, {
headers: {
'Content-Type': 'application/json',
},
});
if (response.ok) {
const result = await response.json();
setData(Array.isArray(result) ? result : [result]);
} else {
throw new Error(`Failed to fetch data: ${response.statusText}`);
}
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
// Create new item
const createItem = async (itemData) => {
try {
const response = await fetch(`${apiUrl}/items`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(itemData),
});
if (response.ok) {
await fetchData();
setShowModal(false);
setFormData({});
} else {
throw new Error('Failed to create item');
}
} catch (err) {
setError(err.message);
}
};
// Update item
const updateItem = async (id, itemData) => {
try {
const response = await fetch(`${apiUrl}/items/${id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(itemData),
});
if (response.ok) {
await fetchData();
setShowModal(false);
setEditingItem(null);
setFormData({});
} else {
throw new Error('Failed to update item');
}
} catch (err) {
setError(err.message);
}
};
// Delete item
const deleteItem = async (id) => {
if (!window.confirm('Are you sure you want to delete this item?')) return;
try {
const response = await fetch(`${apiUrl}/items/${id}`, {
method: 'DELETE',
});
if (response.ok) {
await fetchData();
} else {
throw new Error('Failed to delete item');
}
} catch (err) {
setError(err.message);
}
};
// Handle form submission
const handleSubmit = (e) => {
e.preventDefault();
if (editingItem) {
updateItem(editingItem.id, formData);
} else {
createItem(formData);
}
};
// Open edit modal
const startEdit = (item) => {
setEditingItem(item);
setFormData(item);
setShowModal(true);
};
useEffect(() => {
testConnection();
}, [apiUrl]);
useEffect(() => {
if (isConnected && activeTab === 'data') {
fetchData();
}
}, [isConnected, activeTab]);
const ConnectionStatus = () => (
<div className="flex items-center space-x-2 mb-6">
<div className={`w-3 h-3 rounded-full ${isConnected ? 'bg-green-400' : 'bg-red-400'}`}></div>
<span className={`text-sm font-medium ${isConnected ? 'text-green-600' : 'text-red-600'}`}>
{isConnected ? 'Connected' : 'Disconnected'}
</span>
<span className="text-gray-500"></span>
<span className="text-sm text-gray-600">{apiUrl}</span>
</div>
);
const Dashboard = () => (
<div className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<div className="bg-gradient-to-r from-blue-500 to-blue-600 p-6 rounded-xl text-white">
<div className="flex items-center justify-between">
<div>
<p className="text-blue-100">API Status</p>
<p className="text-2xl font-bold">{isConnected ? 'Online' : 'Offline'}</p>
</div>
<Server className="w-8 h-8 text-blue-200" />
</div>
</div>
<div className="bg-gradient-to-r from-green-500 to-green-600 p-6 rounded-xl text-white">
<div className="flex items-center justify-between">
<div>
<p className="text-green-100">Total Items</p>
<p className="text-2xl font-bold">{data.length}</p>
</div>
<Database className="w-8 h-8 text-green-200" />
</div>
</div>
<div className="bg-gradient-to-r from-purple-500 to-purple-600 p-6 rounded-xl text-white">
<div className="flex items-center justify-between">
<div>
<p className="text-purple-100">Active Users</p>
<p className="text-2xl font-bold">1</p>
</div>
<Users className="w-8 h-8 text-purple-200" />
</div>
</div>
<div className="bg-gradient-to-r from-orange-500 to-orange-600 p-6 rounded-xl text-white">
<div className="flex items-center justify-between">
<div>
<p className="text-orange-100">Activity</p>
<p className="text-2xl font-bold">Live</p>
</div>
<Activity className="w-8 h-8 text-orange-200" />
</div>
</div>
</div>
<div className="bg-white rounded-xl shadow-sm border p-6">
<h3 className="text-lg font-semibold text-gray-800 mb-4">Quick Actions</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<button
onClick={() => setActiveTab('data')}
className="p-4 border-2 border-dashed border-gray-200 rounded-lg hover:border-blue-300 hover:bg-blue-50 transition-colors"
>
<Database className="w-6 h-6 text-gray-400 mx-auto mb-2" />
<p className="text-sm font-medium text-gray-600">View Data</p>
</button>
<button
onClick={() => {
setFormData({});
setEditingItem(null);
setShowModal(true);
}}
className="p-4 border-2 border-dashed border-gray-200 rounded-lg hover:border-green-300 hover:bg-green-50 transition-colors"
>
<Plus className="w-6 h-6 text-gray-400 mx-auto mb-2" />
<p className="text-sm font-medium text-gray-600">Add Item</p>
</button>
<button
onClick={testConnection}
className="p-4 border-2 border-dashed border-gray-200 rounded-lg hover:border-purple-300 hover:bg-purple-50 transition-colors"
>
<RefreshCw className="w-6 h-6 text-gray-400 mx-auto mb-2" />
<p className="text-sm font-medium text-gray-600">Refresh</p>
</button>
</div>
</div>
</div>
);
const DataView = () => (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h2 className="text-xl font-semibold text-gray-800">Data Management</h2>
<button
onClick={() => {
setFormData({});
setEditingItem(null);
setShowModal(true);
}}
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors flex items-center space-x-2"
>
<Plus className="w-4 h-4" />
<span>Add New</span>
</button>
</div>
{loading ? (
<div className="flex justify-center items-center py-12">
<RefreshCw className="w-6 h-6 animate-spin text-blue-600" />
<span className="ml-2 text-gray-600">Loading...</span>
</div>
) : (
<div className="bg-white rounded-xl shadow-sm border overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-50">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">ID</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Description</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{data.length === 0 ? (
<tr>
<td colSpan="4" className="px-6 py-12 text-center text-gray-500">
No data available. Add some items to get started.
</td>
</tr>
) : (
data.map((item, index) => (
<tr key={item.id || index} className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{item.id || index + 1}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{item.name || item.title || 'N/A'}
</td>
<td className="px-6 py-4 text-sm text-gray-600 max-w-xs truncate">
{item.description || item.content || 'No description'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
<div className="flex space-x-2">
<button
onClick={() => startEdit(item)}
className="text-blue-600 hover:text-blue-900 p-1 rounded"
>
<Edit3 className="w-4 h-4" />
</button>
<button
onClick={() => deleteItem(item.id)}
className="text-red-600 hover:text-red-900 p-1 rounded"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
)}
</div>
);
const Modal = () => (
showModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-xl max-w-md w-full p-6">
<h3 className="text-lg font-semibold text-gray-800 mb-4">
{editingItem ? 'Edit Item' : 'Add New Item'}
</h3>
<div onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
<input
type="text"
value={formData.name || ''}
onChange={(e) => setFormData({...formData, name: e.target.value})}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Enter name"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Description</label>
<textarea
value={formData.description || ''}
onChange={(e) => setFormData({...formData, description: e.target.value})}
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
rows="3"
placeholder="Enter description"
/>
</div>
<div className="flex space-x-3 pt-4">
<button
type="button"
onClick={() => {
setShowModal(false);
setEditingItem(null);
setFormData({});
}}
className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors"
>
Cancel
</button>
<button
onClick={handleSubmit}
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
{editingItem ? 'Update' : 'Create'}
</button>
</div>
</div>
</div>
</div>
)
);
return (
<div className="min-h-screen bg-gray-100">
<div className="bg-white shadow-sm border-b">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center py-4">
<div>
<h1 className="text-2xl font-bold text-gray-900">FastAPI Dashboard</h1>
<p className="text-sm text-gray-600">Manage your backend data and monitor API status</p>
</div>
<div className="flex items-center space-x-4">
<input
type="text"
value={apiUrl}
onChange={(e) => setApiUrl(e.target.value)}
className="px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="API URL"
/>
<button
onClick={testConnection}
disabled={loading}
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 flex items-center space-x-2"
>
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
<span>Test</span>
</button>
</div>
</div>
<div className="flex space-x-8">
<button
onClick={() => setActiveTab('dashboard')}
className={`py-2 px-1 border-b-2 font-medium text-sm ${
activeTab === 'dashboard'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
Dashboard
</button>
<button
onClick={() => setActiveTab('data')}
className={`py-2 px-1 border-b-2 font-medium text-sm ${
activeTab === 'data'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700'
}`}
>
Data
</button>
</div>
</div>
</div>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<ConnectionStatus />
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 mb-6 flex items-center space-x-2">
<AlertCircle className="w-5 h-5 text-red-600" />
<span className="text-red-700">{error}</span>
</div>
)}
{activeTab === 'dashboard' ? <Dashboard /> : <DataView />}
</div>
<Modal />
</div>
);
};
export default FastAPIFrontend;
@@ -0,0 +1,233 @@
import React, { useState, useEffect } from 'react';
import {
Server,
Database,
Users,
Activity,
RefreshCw,
Plus,
Eye,
AlertCircle,
CheckCircle,
XCircle
} from 'lucide-react';
const Dashboard = () => {
const [apiUrl, setApiUrl] = useState('http://localhost:8000');
const [connectionStatus, setConnectionStatus] = useState('disconnected');
const [isLoading, setIsLoading] = useState(false);
const [activeTab, setActiveTab] = useState('dashboard');
const testConnection = async () => {
setIsLoading(true);
try {
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 1000));
setConnectionStatus('connected');
} catch (error) {
setConnectionStatus('error');
}
setIsLoading(false);
};
const getStatusColor = () => {
switch (connectionStatus) {
case 'connected': return 'text-green-600';
case 'error': return 'text-red-600';
default: return 'text-orange-600';
}
};
const getStatusIcon = () => {
switch (connectionStatus) {
case 'connected': return <CheckCircle className="w-4 h-4" />;
case 'error': return <XCircle className="w-4 h-4" />;
default: return <AlertCircle className="w-4 h-4" />;
}
};
const getStatusText = () => {
switch (connectionStatus) {
case 'connected': return 'Connected';
case 'error': return 'Connection Error';
default: return 'Disconnected';
}
};
return (
<div className="min-h-screen bg-gray-50">
{/* Header */}
<div className="bg-white shadow-sm border-b">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center py-6">
<div>
<h1 className="text-3xl font-bold text-gray-900">FastAPI Dashboard</h1>
<p className="text-gray-600 mt-1">Manage your backend data and monitor API status</p>
</div>
<div className="flex items-center space-x-4">
<div className={`flex items-center space-x-2 ${getStatusColor()}`}>
{getStatusIcon()}
<span className="font-medium">{getStatusText()}</span>
</div>
</div>
</div>
</div>
</div>
{/* Connection Section */}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="bg-white rounded-lg shadow-sm border p-6 mb-8">
<h2 className="text-lg font-semibold text-gray-900 mb-4">API Connection</h2>
<div className="flex items-center space-x-4">
<div className="flex-1">
<input
type="text"
value={apiUrl}
onChange={(e) => setApiUrl(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Enter API URL"
/>
</div>
<button
onClick={testConnection}
disabled={isLoading}
className="inline-flex items-center px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? (
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
) : (
<RefreshCw className="w-4 h-4 mr-2" />
)}
Test Connection
</button>
</div>
{connectionStatus === 'error' && (
<div className="mt-4 p-4 bg-red-50 border border-red-200 rounded-md">
<div className="flex items-center">
<XCircle className="w-5 h-5 text-red-400 mr-2" />
<span className="text-red-800">NetworkError when attempting to fetch resource.</span>
</div>
</div>
)}
</div>
{/* Navigation Tabs */}
<div className="mb-8">
<div className="border-b border-gray-200">
<nav className="flex space-x-8">
<button
onClick={() => setActiveTab('dashboard')}
className={`py-2 px-1 border-b-2 font-medium text-sm ${
activeTab === 'dashboard'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
Dashboard
</button>
<button
onClick={() => setActiveTab('data')}
className={`py-2 px-1 border-b-2 font-medium text-sm ${
activeTab === 'data'
? 'border-blue-500 text-blue-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
Data
</button>
</nav>
</div>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
{/* API Status */}
<div className="bg-white rounded-lg shadow-sm border p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">API Status</p>
<p className="text-2xl font-bold text-gray-900 mt-2">
{connectionStatus === 'connected' ? 'Online' : 'Offline'}
</p>
</div>
<div className="bg-orange-100 p-3 rounded-full">
<Server className="w-6 h-6 text-orange-600" />
</div>
</div>
</div>
{/* Total Items */}
<div className="bg-white rounded-lg shadow-sm border p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">Total Items</p>
<p className="text-2xl font-bold text-gray-900 mt-2">0</p>
</div>
<div className="bg-blue-100 p-3 rounded-full">
<Database className="w-6 h-6 text-blue-600" />
</div>
</div>
</div>
{/* Active Users */}
<div className="bg-white rounded-lg shadow-sm border p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">Active Users</p>
<p className="text-2xl font-bold text-gray-900 mt-2">1</p>
</div>
<div className="bg-green-100 p-3 rounded-full">
<Users className="w-6 h-6 text-green-600" />
</div>
</div>
</div>
{/* Activity */}
<div className="bg-white rounded-lg shadow-sm border p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-600">Activity</p>
<p className="text-2xl font-bold text-gray-900 mt-2">Live</p>
</div>
<div className="bg-purple-100 p-3 rounded-full">
<Activity className="w-6 h-6 text-purple-600" />
</div>
</div>
</div>
</div>
{/* Quick Actions */}
<div className="bg-white rounded-lg shadow-sm border p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">Quick Actions</h2>
<div className="flex flex-wrap gap-4">
<button className="inline-flex items-center px-4 py-2 bg-gray-100 text-gray-700 rounded-md hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2">
<Eye className="w-4 h-4 mr-2" />
View Data
</button>
<button className="inline-flex items-center px-4 py-2 bg-green-100 text-green-700 rounded-md hover:bg-green-200 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2">
<Plus className="w-4 h-4 mr-2" />
Add Item
</button>
<button className="inline-flex items-center px-4 py-2 bg-blue-100 text-blue-700 rounded-md hover:bg-blue-200 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2">
<RefreshCw className="w-4 h-4 mr-2" />
Refresh
</button>
</div>
</div>
{/* Content based on active tab */}
{activeTab === 'data' && (
<div className="mt-8 bg-white rounded-lg shadow-sm border p-6">
<h2 className="text-lg font-semibold text-gray-900 mb-4">Data Management</h2>
<div className="text-center py-8">
<Database className="w-12 h-12 text-gray-400 mx-auto mb-4" />
<p className="text-gray-500">No data available. Connect to your API to view data.</p>
</div>
</div>
)}
</div>
</div>
);
};
export default Dashboard;
@@ -0,0 +1,515 @@
import React, { useState, useEffect } from 'react';
import {
Server,
Database,
Users,
Activity,
RefreshCw,
Plus,
Eye,
AlertCircle,
CheckCircle,
XCircle
} from 'lucide-react';
const Dashboard = () => {
const [apiUrl, setApiUrl] = useState('http://127.0.0.1:8000');
const [connectionStatus, setConnectionStatus] = useState('disconnected');
const [isLoading, setIsLoading] = useState(false);
const [activeTab, setActiveTab] = useState('dashboard');
const [members, setMembers] = useState([]);
const [totalMembers, setTotalMembers] = useState(0);
const [loadingData, setLoadingData] = useState(false);
const testConnection = async () => {
setIsLoading(true);
try {
console.log('Testing connection to:', apiUrl);
const response = await fetch(`${apiUrl}/member/100`);
if (response.ok) {
setConnectionStatus('connected');
console.log('Connection successful!');
// Load members data when connection is successful
await loadMembers();
} else {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
} catch (error) {
console.error('Connection error:', error);
setConnectionStatus('error');
}
setIsLoading(false);
};
const loadMembers = async () => {
if (connectionStatus !== 'connected' && !isLoading) return;
setLoadingData(true);
try {
console.log('Loading members from:', `${apiUrl}/members/?skip=0&limit=100`);
const response = await fetch(`${apiUrl}/members/?skip=0&limit=100`);
if (response.ok) {
const data = await response.json();
console.log('Members data:', data);
// Handle different response formats
if (Array.isArray(data)) {
setMembers(data);
setTotalMembers(data.length);
} else if (data.items && Array.isArray(data.items)) {
// If your API returns {items: [...], total: 123} format
setMembers(data.items);
setTotalMembers(data.total || data.items.length);
} else {
console.warn('Unexpected data format:', data);
setMembers([]);
setTotalMembers(0);
}
} else {
throw new Error(`Failed to load members: ${response.status}`);
}
} catch (error) {
console.error('Error loading members:', error);
setMembers([]);
setTotalMembers(0);
}
setLoadingData(false);
};
// Load members when component mounts if already connected
useEffect(() => {
if (connectionStatus === 'connected') {
loadMembers();
}
}, [connectionStatus]);
const getStatusColor = () => {
switch (connectionStatus) {
case 'connected': return '#10b981';
case 'error': return '#ef4444';
default: return '#f59e0b';
}
};
const getStatusIcon = () => {
switch (connectionStatus) {
case 'connected': return <CheckCircle size={16} />;
case 'error': return <XCircle size={16} />;
default: return <AlertCircle size={16} />;
}
};
const getStatusText = () => {
switch (connectionStatus) {
case 'connected': return 'Connected';
case 'error': return 'Connection Error';
default: return 'Disconnected';
}
};
const styles = {
container: {
minHeight: '100vh',
backgroundColor: '#f9fafb',
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif'
},
header: {
backgroundColor: 'white',
boxShadow: '0 1px 3px 0 rgba(0, 0, 0, 0.1)',
borderBottom: '1px solid #e5e7eb'
},
headerContent: {
maxWidth: '1280px',
margin: '0 auto',
padding: '0 24px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
paddingTop: '24px',
paddingBottom: '24px'
},
title: {
fontSize: '30px',
fontWeight: 'bold',
color: '#111827',
margin: 0
},
subtitle: {
color: '#6b7280',
marginTop: '4px',
fontSize: '16px'
},
statusContainer: {
display: 'flex',
alignItems: 'center',
gap: '8px',
color: getStatusColor(),
fontWeight: '500'
},
mainContent: {
maxWidth: '1280px',
margin: '0 auto',
padding: '32px 24px'
},
card: {
backgroundColor: 'white',
borderRadius: '8px',
boxShadow: '0 1px 3px 0 rgba(0, 0, 0, 0.1)',
border: '1px solid #e5e7eb',
padding: '24px',
marginBottom: '32px'
},
cardTitle: {
fontSize: '18px',
fontWeight: '600',
color: '#111827',
marginBottom: '16px'
},
connectionForm: {
display: 'flex',
alignItems: 'center',
gap: '16px'
},
input: {
flex: 1,
padding: '8px 12px',
border: '1px solid #d1d5db',
borderRadius: '6px',
fontSize: '14px',
outline: 'none'
},
button: {
display: 'inline-flex',
alignItems: 'center',
gap: '8px',
padding: '8px 16px',
backgroundColor: '#2563eb',
color: 'white',
border: 'none',
borderRadius: '6px',
fontSize: '14px',
fontWeight: '500',
cursor: 'pointer',
transition: 'background-color 0.2s'
},
buttonDisabled: {
opacity: 0.5,
cursor: 'not-allowed'
},
errorAlert: {
marginTop: '16px',
padding: '16px',
backgroundColor: '#fef2f2',
border: '1px solid #fecaca',
borderRadius: '6px',
display: 'flex',
alignItems: 'center',
gap: '8px'
},
errorText: {
color: '#991b1b'
},
tabs: {
borderBottom: '1px solid #e5e7eb',
marginBottom: '32px'
},
tabsNav: {
display: 'flex',
gap: '32px'
},
tab: {
padding: '8px 4px',
fontSize: '14px',
fontWeight: '500',
border: 'none',
background: 'none',
cursor: 'pointer',
borderBottom: '2px solid transparent',
transition: 'all 0.2s'
},
tabActive: {
borderBottom: '2px solid #2563eb',
color: '#2563eb'
},
tabInactive: {
color: '#6b7280'
},
statsGrid: {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(250px, 1fr))',
gap: '24px',
marginBottom: '32px'
},
statCard: {
backgroundColor: 'white',
borderRadius: '8px',
boxShadow: '0 1px 3px 0 rgba(0, 0, 0, 0.1)',
border: '1px solid #e5e7eb',
padding: '24px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between'
},
statContent: {
flex: 1
},
statLabel: {
fontSize: '14px',
fontWeight: '500',
color: '#6b7280'
},
statValue: {
fontSize: '24px',
fontWeight: 'bold',
color: '#111827',
marginTop: '8px'
},
statIcon: {
padding: '12px',
borderRadius: '50%'
},
iconOrange: {
backgroundColor: '#fed7aa',
color: '#ea580c'
},
iconBlue: {
backgroundColor: '#dbeafe',
color: '#2563eb'
},
iconGreen: {
backgroundColor: '#dcfce7',
color: '#16a34a'
},
iconPurple: {
backgroundColor: '#e9d5ff',
color: '#9333ea'
},
actionButtons: {
display: 'flex',
flexWrap: 'wrap',
gap: '16px'
},
actionButton: {
display: 'inline-flex',
alignItems: 'center',
gap: '8px',
padding: '8px 16px',
border: 'none',
borderRadius: '6px',
fontSize: '14px',
fontWeight: '500',
cursor: 'pointer',
transition: 'background-color 0.2s'
},
actionButtonGray: {
backgroundColor: '#f3f4f6',
color: '#374151'
},
actionButtonGreen: {
backgroundColor: '#dcfce7',
color: '#166534'
},
actionButtonBlue: {
backgroundColor: '#dbeafe',
color: '#1e40af'
},
emptyState: {
textAlign: 'center',
padding: '32px'
},
emptyStateIcon: {
margin: '0 auto 16px',
color: '#9ca3af'
},
emptyStateText: {
color: '#6b7280'
}
};
return (
<div style={styles.container}>
{/* Header */}
<div style={styles.header}>
<div style={styles.headerContent}>
<div>
<h1 style={styles.title}>FastAPI Dashboard</h1>
<p style={styles.subtitle}>Manage your backend data and monitor API status</p>
</div>
<div style={styles.statusContainer}>
{getStatusIcon()}
<span>{getStatusText()}</span>
</div>
</div>
</div>
{/* Main Content */}
<div style={styles.mainContent}>
{/* Connection Section */}
<div style={styles.card}>
<h2 style={styles.cardTitle}>API Connection</h2>
<div style={styles.connectionForm}>
<input
type="text"
value={apiUrl}
onChange={(e) => setApiUrl(e.target.value)}
style={styles.input}
placeholder="Enter API URL"
/>
<button
onClick={testConnection}
disabled={isLoading}
style={{
...styles.button,
...(isLoading ? styles.buttonDisabled : {}),
':hover': { backgroundColor: '#1d4ed8' }
}}
onMouseEnter={(e) => {
if (!isLoading) e.target.style.backgroundColor = '#1d4ed8';
}}
onMouseLeave={(e) => {
if (!isLoading) e.target.style.backgroundColor = '#2563eb';
}}
>
<RefreshCw size={16} style={isLoading ? { animation: 'spin 1s linear infinite' } : {}} />
Test Connection
</button>
</div>
{connectionStatus === 'error' && (
<div style={styles.errorAlert}>
<XCircle size={20} style={{ color: '#dc2626' }} />
<span style={styles.errorText}>NetworkError when attempting to fetch resource.</span>
</div>
)}
</div>
{/* Navigation Tabs */}
<div style={styles.tabs}>
<div style={styles.tabsNav}>
<button
onClick={() => setActiveTab('dashboard')}
style={{
...styles.tab,
...(activeTab === 'dashboard' ? styles.tabActive : styles.tabInactive)
}}
>
Dashboard
</button>
<button
onClick={() => setActiveTab('data')}
style={{
...styles.tab,
...(activeTab === 'data' ? styles.tabActive : styles.tabInactive)
}}
>
Data
</button>
</div>
</div>
{/* Stats Grid */}
<div style={styles.statsGrid}>
{/* API Status */}
<div style={styles.statCard}>
<div style={styles.statContent}>
<p style={styles.statLabel}>API Status</p>
<p style={styles.statValue}>
{connectionStatus === 'connected' ? 'Online' : 'Offline'}
</p>
</div>
<div style={{...styles.statIcon, ...styles.iconOrange}}>
<Server size={24} />
</div>
</div>
{/* Total Items */}
<div style={styles.statCard}>
<div style={styles.statContent}>
<p style={styles.statLabel}>Total Items</p>
<p style={styles.statValue}>0</p>
</div>
<div style={{...styles.statIcon, ...styles.iconBlue}}>
<Database size={24} />
</div>
</div>
{/* Active Users */}
<div style={styles.statCard}>
<div style={styles.statContent}>
<p style={styles.statLabel}>Active Users</p>
<p style={styles.statValue}>1</p>
</div>
<div style={{...styles.statIcon, ...styles.iconGreen}}>
<Users size={24} />
</div>
</div>
{/* Activity */}
<div style={styles.statCard}>
<div style={styles.statContent}>
<p style={styles.statLabel}>Activity</p>
<p style={styles.statValue}>Live</p>
</div>
<div style={{...styles.statIcon, ...styles.iconPurple}}>
<Activity size={24} />
</div>
</div>
</div>
{/* Quick Actions */}
<div style={styles.card}>
<h2 style={styles.cardTitle}>Quick Actions</h2>
<div style={styles.actionButtons}>
<button
style={{...styles.actionButton, ...styles.actionButtonGray}}
onMouseEnter={(e) => e.target.style.backgroundColor = '#e5e7eb'}
onMouseLeave={(e) => e.target.style.backgroundColor = '#f3f4f6'}
>
<Eye size={16} />
View Data
</button>
<button
style={{...styles.actionButton, ...styles.actionButtonGreen}}
onMouseEnter={(e) => e.target.style.backgroundColor = '#bbf7d0'}
onMouseLeave={(e) => e.target.style.backgroundColor = '#dcfce7'}
>
<Plus size={16} />
Add Item
</button>
<button
style={{...styles.actionButton, ...styles.actionButtonBlue}}
onMouseEnter={(e) => e.target.style.backgroundColor = '#bfdbfe'}
onMouseLeave={(e) => e.target.style.backgroundColor = '#dbeafe'}
>
<RefreshCw size={16} />
Refresh
</button>
</div>
</div>
{/* Content based on active tab */}
{activeTab === 'data' && (
<div style={styles.card}>
<h2 style={styles.cardTitle}>Data Management</h2>
<div style={styles.emptyState}>
<Database size={48} style={styles.emptyStateIcon} />
<p style={styles.emptyStateText}>No data available. Connect to your API to view data.</p>
</div>
</div>
)}
</div>
<style>
{`
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
`}
</style>
</div>
);
};
export default Dashboard;
+3
View File
@@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
+12
View File
@@ -0,0 +1,12 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import FastAPIFrontend from './FastAPIFrontend';
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
root.render(
<React.StrictMode>
<FastAPIFrontend />
</React.StrictMode>
);
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+113
View File
@@ -0,0 +1,113 @@
{
"compilerOptions": {
/* Visit https://aka.ms/tsconfig to read more about this file */
/* Projects */
// "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
// "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
/* Language and Environment */
"target": "es2016", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
// "jsx": "preserve", /* Specify what JSX code is generated. */
// "libReplacement": true, /* Enable lib replacement. */
// "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
// "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
/* Modules */
"module": "commonjs", /* Specify what module code is generated. */
// "rootDir": "./", /* Specify the root folder within your source files. */
// "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
// "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
// "types": [], /* Specify type package names to be included without being referenced in a source file. */
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
// "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
// "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */
// "rewriteRelativeImportExtensions": true, /* Rewrite '.ts', '.tsx', '.mts', and '.cts' file extensions in relative import paths to their JavaScript equivalent in output files. */
// "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */
// "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */
// "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */
// "noUncheckedSideEffectImports": true, /* Check side effect imports. */
// "resolveJsonModule": true, /* Enable importing .json files. */
// "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
/* JavaScript Support */
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
/* Emit */
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
// "noEmit": true, /* Disable emitting files from a compilation. */
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
// "outDir": "./", /* Specify an output folder for all emitted files. */
// "removeComments": true, /* Disable emitting comments. */
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
// "newLine": "crlf", /* Set the newline character for emitting files. */
// "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
// "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
// "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
/* Interop Constraints */
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
// "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */
// "isolatedDeclarations": true, /* Require sufficient annotation on exports so other tools can trivially generate declaration files. */
// "erasableSyntaxOnly": true, /* Do not allow runtime constructs that are not part of ECMAScript. */
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
/* Type Checking */
"strict": true, /* Enable all strict type-checking options. */
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
// "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
// "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
// "strictBuiltinIteratorReturn": true, /* Built-in iterators are instantiated with a 'TReturn' type of 'undefined' instead of 'any'. */
// "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
// "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
// "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
// "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
/* Completeness */
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
"skipLibCheck": true /* Skip type checking all .d.ts files. */
}
}