Compare commits

..

No commits in common. "main" and "feature/exclusive-summary" have entirely different histories.

21 changed files with 80 additions and 1110 deletions

View File

@ -1 +0,0 @@
{}

View File

@ -8,7 +8,6 @@ const (
RoleCashier UserRole = "cashier" RoleCashier UserRole = "cashier"
RoleWaiter UserRole = "waiter" RoleWaiter UserRole = "waiter"
RoleOwner UserRole = "owner" RoleOwner UserRole = "owner"
RolePurchasing UserRole = "purchasing"
) )
func GetAllUserRoles() []UserRole { func GetAllUserRoles() []UserRole {
@ -18,7 +17,6 @@ func GetAllUserRoles() []UserRole {
RoleCashier, RoleCashier,
RoleWaiter, RoleWaiter,
RoleOwner, RoleOwner,
RolePurchasing,
} }
} }

View File

@ -18,7 +18,6 @@ type PaymentMethodAnalyticsRequest struct {
type PaymentMethodAnalyticsResponse struct { type PaymentMethodAnalyticsResponse struct {
OrganizationID uuid.UUID `json:"organization_id"` OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"` OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
DateFrom time.Time `json:"date_from"` DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"` DateTo time.Time `json:"date_to"`
GroupBy string `json:"group_by"` GroupBy string `json:"group_by"`
@ -55,7 +54,6 @@ type SalesAnalyticsRequest struct {
type SalesAnalyticsResponse struct { type SalesAnalyticsResponse struct {
OrganizationID uuid.UUID `json:"organization_id"` OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"` OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
DateFrom time.Time `json:"date_from"` DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"` DateTo time.Time `json:"date_to"`
GroupBy string `json:"group_by"` GroupBy string `json:"group_by"`
@ -163,7 +161,6 @@ type ProductAnalyticsRequest struct {
type ProductAnalyticsResponse struct { type ProductAnalyticsResponse struct {
OrganizationID uuid.UUID `json:"organization_id"` OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"` OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
DateFrom time.Time `json:"date_from"` DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"` DateTo time.Time `json:"date_to"`
Data []ProductAnalyticsData `json:"data"` Data []ProductAnalyticsData `json:"data"`
@ -201,7 +198,6 @@ type ProductAnalyticsPerCategoryRequest struct {
type ProductAnalyticsPerCategoryResponse struct { type ProductAnalyticsPerCategoryResponse struct {
OrganizationID uuid.UUID `json:"organization_id"` OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"` OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
DateFrom time.Time `json:"date_from"` DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"` DateTo time.Time `json:"date_to"`
Data []ProductAnalyticsPerCategoryData `json:"data"` Data []ProductAnalyticsPerCategoryData `json:"data"`
@ -231,7 +227,6 @@ type DashboardAnalyticsRequest struct {
type DashboardAnalyticsResponse struct { type DashboardAnalyticsResponse struct {
OrganizationID uuid.UUID `json:"organization_id"` OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"` OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
DateFrom time.Time `json:"date_from"` DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"` DateTo time.Time `json:"date_to"`
Overview DashboardOverview `json:"overview"` Overview DashboardOverview `json:"overview"`
@ -248,9 +243,6 @@ type DashboardOverview struct {
TotalCustomers int64 `json:"total_customers"` TotalCustomers int64 `json:"total_customers"`
VoidedOrders int64 `json:"voided_orders"` VoidedOrders int64 `json:"voided_orders"`
RefundedOrders int64 `json:"refunded_orders"` RefundedOrders int64 `json:"refunded_orders"`
TotalItemSold int64 `json:"total_item_sold"`
TotalLowStock int64 `json:"total_low_stock"`
TotalProductActive int64 `json:"total_product_active"`
} }
type ProfitLossAnalyticsRequest struct { type ProfitLossAnalyticsRequest struct {
@ -264,7 +256,6 @@ type ProfitLossAnalyticsRequest struct {
type ProfitLossAnalyticsResponse struct { type ProfitLossAnalyticsResponse struct {
OrganizationID uuid.UUID `json:"organization_id"` OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"` OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
DateFrom time.Time `json:"date_from"` DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"` DateTo time.Time `json:"date_to"`
GroupBy string `json:"group_by"` GroupBy string `json:"group_by"`
@ -272,28 +263,10 @@ type ProfitLossAnalyticsResponse struct {
Data []ProfitLossData `json:"data"` Data []ProfitLossData `json:"data"`
ProductData []ProductProfitData `json:"product_data"` ProductData []ProductProfitData `json:"product_data"`
MainSummary []ProfitLossSummaryRow `json:"main_summary"` MainSummary []ProfitLossSummaryRow `json:"main_summary"`
Purchasing ProfitLossPurchasing `json:"purchasing"`
OperationalExpenses []OperationalExpenseItem `json:"operational_expenses"` OperationalExpenses []OperationalExpenseItem `json:"operational_expenses"`
OperationalExpensesTotal float64 `json:"operational_expenses_total"` OperationalExpensesTotal float64 `json:"operational_expenses_total"`
} }
type ProfitLossPurchasing struct {
TodayTotal float64 `json:"today_total"`
MtdTotal float64 `json:"mtd_total"`
TodayRawMaterial float64 `json:"today_raw_material"`
MtdRawMaterial float64 `json:"mtd_raw_material"`
TodayExpense float64 `json:"today_expense"`
MtdExpense float64 `json:"mtd_expense"`
Items []ProfitLossPurchasingItem `json:"items"`
}
type ProfitLossPurchasingItem struct {
Date time.Time `json:"date"`
Item string `json:"item"`
Quantity float64 `json:"quantity"`
Nominal float64 `json:"nominal"`
}
type ProfitLossSummary struct { type ProfitLossSummary struct {
TotalRevenue float64 `json:"total_revenue"` TotalRevenue float64 `json:"total_revenue"`
TotalCost float64 `json:"total_cost"` TotalCost float64 `json:"total_cost"`
@ -376,7 +349,6 @@ type ExclusiveSummaryMTDRequest struct {
type ExclusiveSummaryPeriodResponse struct { type ExclusiveSummaryPeriodResponse struct {
OrganizationID uuid.UUID `json:"organization_id"` OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"` OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
Period ExclusiveSummaryPeriodRange `json:"period"` Period ExclusiveSummaryPeriodRange `json:"period"`
Summary ExclusiveSummaryPeriodSummary `json:"summary"` Summary ExclusiveSummaryPeriodSummary `json:"summary"`
Reimburse ExclusiveSummaryReimburse `json:"reimburse"` Reimburse ExclusiveSummaryReimburse `json:"reimburse"`
@ -436,7 +408,6 @@ type ExclusiveSummaryDailyTransaction struct {
type ExclusiveSummaryMonthlyResponse struct { type ExclusiveSummaryMonthlyResponse struct {
OrganizationID uuid.UUID `json:"organization_id"` OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"` OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
Month string `json:"month"` Month string `json:"month"`
Summary ExclusiveSummaryMonthlySummary `json:"summary"` Summary ExclusiveSummaryMonthlySummary `json:"summary"`
Periods []ExclusiveSummaryMonthlyPeriod `json:"periods"` Periods []ExclusiveSummaryMonthlyPeriod `json:"periods"`

View File

@ -12,14 +12,14 @@ type CreateUserRequest struct {
Name string `json:"name" validate:"required,min=1,max=255"` Name string `json:"name" validate:"required,min=1,max=255"`
Email string `json:"email" validate:"required,email"` Email string `json:"email" validate:"required,email"`
Password string `json:"password" validate:"required,min=6"` Password string `json:"password" validate:"required,min=6"`
Role string `json:"role" validate:"required,oneof=admin manager cashier waiter owner purchasing"` Role string `json:"role" validate:"required,oneof=admin manager cashier waiter"`
Permissions map[string]interface{} `json:"permissions,omitempty"` Permissions map[string]interface{} `json:"permissions,omitempty"`
} }
type UpdateUserRequest struct { type UpdateUserRequest struct {
Name *string `json:"name,omitempty" validate:"omitempty,min=1,max=255"` Name *string `json:"name,omitempty" validate:"omitempty,min=1,max=255"`
Email *string `json:"email,omitempty" validate:"omitempty,email"` Email *string `json:"email,omitempty" validate:"omitempty,email"`
Role *string `json:"role,omitempty" validate:"omitempty,oneof=admin manager cashier waiter owner purchasing"` Role *string `json:"role,omitempty" validate:"omitempty,oneof=admin manager cashier waiter"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"` OutletID *uuid.UUID `json:"outlet_id,omitempty"`
IsActive *bool `json:"is_active,omitempty"` IsActive *bool `json:"is_active,omitempty"`
Permissions *map[string]interface{} `json:"permissions,omitempty"` Permissions *map[string]interface{} `json:"permissions,omitempty"`

View File

@ -120,9 +120,6 @@ type DashboardOverview struct {
TotalCustomers int64 `json:"total_customers"` TotalCustomers int64 `json:"total_customers"`
VoidedOrders int64 `json:"voided_orders"` VoidedOrders int64 `json:"voided_orders"`
RefundedOrders int64 `json:"refunded_orders"` RefundedOrders int64 `json:"refunded_orders"`
TotalItemSold int64 `json:"total_item_sold"`
TotalLowStock int64 `json:"total_low_stock"`
TotalProductActive int64 `json:"total_product_active"`
} }
type ProfitLossAnalytics struct { type ProfitLossAnalytics struct {
@ -133,25 +130,11 @@ type ProfitLossAnalytics struct {
TodayCost float64 TodayCost float64
MtdRevenue float64 MtdRevenue float64
MtdCost float64 MtdCost float64
TodayPurchasing float64
MtdPurchasing float64
TodayPurchasingRawMaterial float64
MtdPurchasingRawMaterial float64
TodayPurchasingExpense float64
MtdPurchasingExpense float64
PurchasingItems []PurchasingItemDetail
TodayExpenseByCategory []ExpenseCategoryTotal TodayExpenseByCategory []ExpenseCategoryTotal
MtdExpenseByCategory []ExpenseCategoryTotal MtdExpenseByCategory []ExpenseCategoryTotal
OperationalExpenseItems []OperationalExpenseItem OperationalExpenseItems []OperationalExpenseItem
} }
type PurchasingItemDetail struct {
Date time.Time
Item string
Quantity float64
Amount float64
}
type ProfitLossSummary struct { type ProfitLossSummary struct {
TotalRevenue float64 TotalRevenue float64
TotalCost float64 TotalCost float64

View File

@ -17,8 +17,6 @@ const (
RoleManager UserRole = "manager" RoleManager UserRole = "manager"
RoleCashier UserRole = "cashier" RoleCashier UserRole = "cashier"
RoleWaiter UserRole = "waiter" RoleWaiter UserRole = "waiter"
RoleOwner UserRole = "owner"
RolePurchasing UserRole = "purchasing"
) )
type Permissions map[string]interface{} type Permissions map[string]interface{}
@ -48,7 +46,7 @@ type User struct {
Name string `gorm:"not null;size:255" json:"name" validate:"required,min=1,max=255"` Name string `gorm:"not null;size:255" json:"name" validate:"required,min=1,max=255"`
Email string `gorm:"uniqueIndex;not null;size:255" json:"email" validate:"required,email"` Email string `gorm:"uniqueIndex;not null;size:255" json:"email" validate:"required,email"`
PasswordHash string `gorm:"not null;size:255" json:"-"` PasswordHash string `gorm:"not null;size:255" json:"-"`
Role UserRole `gorm:"not null;size:50" json:"role" validate:"required,oneof=admin manager cashier waiter owner purchasing"` Role UserRole `gorm:"not null;size:50" json:"role" validate:"required,oneof=admin manager cashier waiter"`
Permissions Permissions `gorm:"type:jsonb;default:'{}'" json:"permissions"` Permissions Permissions `gorm:"type:jsonb;default:'{}'" json:"permissions"`
IsActive bool `gorm:"default:true" json:"is_active"` IsActive bool `gorm:"default:true" json:"is_active"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`

View File

@ -66,35 +66,3 @@ func (h *ReportHandler) GetDailyTransactionReportPDF(c *gin.Context) {
"file_name": fileName, "file_name": fileName,
}), "ReportHandler::GetDailyTransactionReportPDF") }), "ReportHandler::GetDailyTransactionReportPDF")
} }
func (h *ReportHandler) GetProfitLossReportPDF(c *gin.Context) {
ctx := c.Request.Context()
ci := appcontext.FromGinContext(ctx)
outletID := h.resolveOutletID(c, ci.OutletID)
var dayPtr *time.Time
if d := c.Query("date"); d != "" {
if t, err := time.Parse("2006-01-02", d); err == nil {
dayPtr = &t
}
}
user, err := h.userService.GetUserByID(ctx, ci.UserID)
var genBy string
if err != nil {
genBy = ci.UserID.String()
} else {
genBy = user.Name
}
publicURL, fileName, err := h.reportService.GenerateProfitLossPDF(ctx, ci.OrganizationID.String(), outletID, dayPtr, genBy)
if err != nil {
util.HandleResponse(c.Writer, c.Request, contract.BuildErrorResponse([]*contract.ResponseError{contract.NewResponseError("internal_error", "ReportHandler::GetProfitLossReportPDF", err.Error())}), "ReportHandler::GetProfitLossReportPDF")
return
}
util.HandleResponse(c.Writer, c.Request, contract.BuildSuccessResponse(map[string]string{
"url": publicURL,
"file_name": fileName,
}), "ReportHandler::GetProfitLossReportPDF")
}

View File

@ -82,11 +82,7 @@ func (m *AuthMiddleware) RequireRole(allowedRoles ...string) gin.HandlerFunc {
} }
func (m *AuthMiddleware) RequireAdminOrManager() gin.HandlerFunc { func (m *AuthMiddleware) RequireAdminOrManager() gin.HandlerFunc {
return m.RequireRole("superadmin", "admin", "manager", "owner", "purchasing") return m.RequireRole("superadmin", "admin", "manager")
}
func (m *AuthMiddleware) RequireAdminOrManagerOrPurchasing() gin.HandlerFunc {
return m.RequireRole("superadmin", "admin", "manager", "owner", "purchasing")
} }
func (m *AuthMiddleware) RequireAdmin() gin.HandlerFunc { func (m *AuthMiddleware) RequireAdmin() gin.HandlerFunc {

View File

@ -19,7 +19,6 @@ type PaymentMethodAnalyticsRequest struct {
type PaymentMethodAnalyticsResponse struct { type PaymentMethodAnalyticsResponse struct {
OrganizationID uuid.UUID `json:"organization_id"` OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"` OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
DateFrom time.Time `json:"date_from"` DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"` DateTo time.Time `json:"date_to"`
GroupBy string `json:"group_by"` GroupBy string `json:"group_by"`
@ -59,7 +58,6 @@ type SalesAnalyticsRequest struct {
type SalesAnalyticsResponse struct { type SalesAnalyticsResponse struct {
OrganizationID uuid.UUID `json:"organization_id"` OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"` OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
DateFrom time.Time `json:"date_from"` DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"` DateTo time.Time `json:"date_to"`
GroupBy string `json:"group_by"` GroupBy string `json:"group_by"`
@ -173,7 +171,6 @@ type ProductAnalyticsRequest struct {
type ProductAnalyticsResponse struct { type ProductAnalyticsResponse struct {
OrganizationID uuid.UUID `json:"organization_id"` OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"` OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
DateFrom time.Time `json:"date_from"` DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"` DateTo time.Time `json:"date_to"`
Data []ProductAnalyticsData `json:"data"` Data []ProductAnalyticsData `json:"data"`
@ -211,7 +208,6 @@ type ProductAnalyticsPerCategoryRequest struct {
type ProductAnalyticsPerCategoryResponse struct { type ProductAnalyticsPerCategoryResponse struct {
OrganizationID uuid.UUID `json:"organization_id"` OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"` OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
DateFrom time.Time `json:"date_from"` DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"` DateTo time.Time `json:"date_to"`
Data []ProductAnalyticsPerCategoryData `json:"data"` Data []ProductAnalyticsPerCategoryData `json:"data"`
@ -241,7 +237,6 @@ type DashboardAnalyticsRequest struct {
type DashboardAnalyticsResponse struct { type DashboardAnalyticsResponse struct {
OrganizationID uuid.UUID `json:"organization_id"` OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"` OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
DateFrom time.Time `json:"date_from"` DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"` DateTo time.Time `json:"date_to"`
Overview DashboardOverview `json:"overview"` Overview DashboardOverview `json:"overview"`
@ -258,9 +253,6 @@ type DashboardOverview struct {
TotalCustomers int64 `json:"total_customers"` TotalCustomers int64 `json:"total_customers"`
VoidedOrders int64 `json:"voided_orders"` VoidedOrders int64 `json:"voided_orders"`
RefundedOrders int64 `json:"refunded_orders"` RefundedOrders int64 `json:"refunded_orders"`
TotalItemSold int64 `json:"total_item_sold"`
TotalLowStock int64 `json:"total_low_stock"`
TotalProductActive int64 `json:"total_product_active"`
} }
type ProfitLossAnalyticsRequest struct { type ProfitLossAnalyticsRequest struct {
@ -274,7 +266,6 @@ type ProfitLossAnalyticsRequest struct {
type ProfitLossAnalyticsResponse struct { type ProfitLossAnalyticsResponse struct {
OrganizationID uuid.UUID `json:"organization_id"` OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"` OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
DateFrom time.Time `json:"date_from"` DateFrom time.Time `json:"date_from"`
DateTo time.Time `json:"date_to"` DateTo time.Time `json:"date_to"`
GroupBy string `json:"group_by"` GroupBy string `json:"group_by"`
@ -282,28 +273,10 @@ type ProfitLossAnalyticsResponse struct {
Data []ProfitLossData `json:"data"` Data []ProfitLossData `json:"data"`
ProductData []ProductProfitData `json:"product_data"` ProductData []ProductProfitData `json:"product_data"`
MainSummary []ProfitLossSummaryRow `json:"main_summary"` MainSummary []ProfitLossSummaryRow `json:"main_summary"`
Purchasing ProfitLossPurchasing `json:"purchasing"`
OperationalExpenses []OperationalExpenseItem `json:"operational_expenses"` OperationalExpenses []OperationalExpenseItem `json:"operational_expenses"`
OperationalExpensesTotal float64 `json:"operational_expenses_total"` OperationalExpensesTotal float64 `json:"operational_expenses_total"`
} }
type ProfitLossPurchasing struct {
TodayTotal float64 `json:"today_total"`
MtdTotal float64 `json:"mtd_total"`
TodayRawMaterial float64 `json:"today_raw_material"`
MtdRawMaterial float64 `json:"mtd_raw_material"`
TodayExpense float64 `json:"today_expense"`
MtdExpense float64 `json:"mtd_expense"`
Items []ProfitLossPurchasingItem `json:"items"`
}
type ProfitLossPurchasingItem struct {
Date time.Time `json:"date"`
Item string `json:"item"`
Quantity float64 `json:"quantity"`
Nominal float64 `json:"nominal"`
}
type ProfitLossSummary struct { type ProfitLossSummary struct {
TotalRevenue float64 `json:"total_revenue"` TotalRevenue float64 `json:"total_revenue"`
TotalCost float64 `json:"total_cost"` TotalCost float64 `json:"total_cost"`
@ -386,7 +359,6 @@ type ExclusiveSummaryMTDRequest struct {
type ExclusiveSummaryPeriodResponse struct { type ExclusiveSummaryPeriodResponse struct {
OrganizationID uuid.UUID `json:"organization_id"` OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"` OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
Period ExclusiveSummaryPeriodRange `json:"period"` Period ExclusiveSummaryPeriodRange `json:"period"`
Summary ExclusiveSummaryPeriodSummary `json:"summary"` Summary ExclusiveSummaryPeriodSummary `json:"summary"`
Reimburse ExclusiveSummaryReimburse `json:"reimburse"` Reimburse ExclusiveSummaryReimburse `json:"reimburse"`
@ -446,7 +418,6 @@ type ExclusiveSummaryDailyTransaction struct {
type ExclusiveSummaryMonthlyResponse struct { type ExclusiveSummaryMonthlyResponse struct {
OrganizationID uuid.UUID `json:"organization_id"` OrganizationID uuid.UUID `json:"organization_id"`
OutletID *uuid.UUID `json:"outlet_id,omitempty"` OutletID *uuid.UUID `json:"outlet_id,omitempty"`
OutletName *string `json:"outlet_name,omitempty"`
Month string `json:"month"` Month string `json:"month"`
Summary ExclusiveSummaryMonthlySummary `json:"summary"` Summary ExclusiveSummaryMonthlySummary `json:"summary"`
Periods []ExclusiveSummaryMonthlyPeriod `json:"periods"` Periods []ExclusiveSummaryMonthlyPeriod `json:"periods"`

View File

@ -65,10 +65,8 @@ func (u *User) HasPermission(requiredRole constants.UserRole) bool {
roleHierarchy := map[constants.UserRole]int{ roleHierarchy := map[constants.UserRole]int{
constants.RoleWaiter: 1, constants.RoleWaiter: 1,
constants.RoleCashier: 2, constants.RoleCashier: 2,
constants.RolePurchasing: 3, constants.RoleManager: 3,
constants.RoleManager: 4, constants.RoleAdmin: 4,
constants.RoleAdmin: 5,
constants.RoleOwner: 6,
} }
userLevel := roleHierarchy[u.Role] userLevel := roleHierarchy[u.Role]

View File

@ -9,8 +9,6 @@ import (
"apskel-pos-be/internal/entities" "apskel-pos-be/internal/entities"
"apskel-pos-be/internal/models" "apskel-pos-be/internal/models"
"apskel-pos-be/internal/repository" "apskel-pos-be/internal/repository"
"github.com/google/uuid"
) )
type AnalyticsProcessor interface { type AnalyticsProcessor interface {
@ -38,18 +36,6 @@ func NewAnalyticsProcessorImpl(analyticsRepo repository.AnalyticsRepository, exp
} }
} }
// resolveOutletName fetches the outlet name from the database if outletID is provided
func (p *AnalyticsProcessorImpl) resolveOutletName(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID) *string {
if outletID == nil {
return nil
}
name, err := p.analyticsRepo.GetOutletName(ctx, organizationID, *outletID)
if err != nil || name == "" {
return nil
}
return &name
}
func (p *AnalyticsProcessorImpl) GetPaymentMethodAnalytics(ctx context.Context, req *models.PaymentMethodAnalyticsRequest) (*models.PaymentMethodAnalyticsResponse, error) { func (p *AnalyticsProcessorImpl) GetPaymentMethodAnalytics(ctx context.Context, req *models.PaymentMethodAnalyticsRequest) (*models.PaymentMethodAnalyticsResponse, error) {
if req.DateFrom.After(req.DateTo) { if req.DateFrom.After(req.DateTo) {
return nil, fmt.Errorf("date_from cannot be after date_to") return nil, fmt.Errorf("date_from cannot be after date_to")
@ -104,7 +90,6 @@ func (p *AnalyticsProcessorImpl) GetPaymentMethodAnalytics(ctx context.Context,
return &models.PaymentMethodAnalyticsResponse{ return &models.PaymentMethodAnalyticsResponse{
OrganizationID: req.OrganizationID, OrganizationID: req.OrganizationID,
OutletID: req.OutletID, OutletID: req.OutletID,
OutletName: p.resolveOutletName(ctx, req.OrganizationID, req.OutletID),
DateFrom: req.DateFrom, DateFrom: req.DateFrom,
DateTo: req.DateTo, DateTo: req.DateTo,
GroupBy: req.GroupBy, GroupBy: req.GroupBy,
@ -179,7 +164,6 @@ func (p *AnalyticsProcessorImpl) GetSalesAnalytics(ctx context.Context, req *mod
return &models.SalesAnalyticsResponse{ return &models.SalesAnalyticsResponse{
OrganizationID: req.OrganizationID, OrganizationID: req.OrganizationID,
OutletID: req.OutletID, OutletID: req.OutletID,
OutletName: p.resolveOutletName(ctx, req.OrganizationID, req.OutletID),
DateFrom: req.DateFrom, DateFrom: req.DateFrom,
DateTo: req.DateTo, DateTo: req.DateTo,
GroupBy: req.GroupBy, GroupBy: req.GroupBy,
@ -311,7 +295,6 @@ func (p *AnalyticsProcessorImpl) GetProductAnalytics(ctx context.Context, req *m
return &models.ProductAnalyticsResponse{ return &models.ProductAnalyticsResponse{
OrganizationID: req.OrganizationID, OrganizationID: req.OrganizationID,
OutletID: req.OutletID, OutletID: req.OutletID,
OutletName: p.resolveOutletName(ctx, req.OrganizationID, req.OutletID),
DateFrom: req.DateFrom, DateFrom: req.DateFrom,
DateTo: req.DateTo, DateTo: req.DateTo,
Data: resultData, Data: resultData,
@ -349,7 +332,6 @@ func (p *AnalyticsProcessorImpl) GetProductAnalyticsPerCategory(ctx context.Cont
return &models.ProductAnalyticsPerCategoryResponse{ return &models.ProductAnalyticsPerCategoryResponse{
OrganizationID: req.OrganizationID, OrganizationID: req.OrganizationID,
OutletID: req.OutletID, OutletID: req.OutletID,
OutletName: p.resolveOutletName(ctx, req.OrganizationID, req.OutletID),
DateFrom: req.DateFrom, DateFrom: req.DateFrom,
DateTo: req.DateTo, DateTo: req.DateTo,
Data: resultData, Data: resultData,
@ -411,7 +393,6 @@ func (p *AnalyticsProcessorImpl) GetDashboardAnalytics(ctx context.Context, req
return &models.DashboardAnalyticsResponse{ return &models.DashboardAnalyticsResponse{
OrganizationID: req.OrganizationID, OrganizationID: req.OrganizationID,
OutletID: req.OutletID, OutletID: req.OutletID,
OutletName: p.resolveOutletName(ctx, req.OrganizationID, req.OutletID),
DateFrom: req.DateFrom, DateFrom: req.DateFrom,
DateTo: req.DateTo, DateTo: req.DateTo,
Overview: models.DashboardOverview{ Overview: models.DashboardOverview{
@ -421,9 +402,6 @@ func (p *AnalyticsProcessorImpl) GetDashboardAnalytics(ctx context.Context, req
TotalCustomers: overview.TotalCustomers, TotalCustomers: overview.TotalCustomers,
VoidedOrders: overview.VoidedOrders, VoidedOrders: overview.VoidedOrders,
RefundedOrders: overview.RefundedOrders, RefundedOrders: overview.RefundedOrders,
TotalItemSold: overview.TotalItemSold,
TotalLowStock: overview.TotalLowStock,
TotalProductActive: overview.TotalProductActive,
}, },
TopProducts: topProducts.Data, TopProducts: topProducts.Data,
PaymentMethods: paymentMethods.Data, PaymentMethods: paymentMethods.Data,
@ -626,20 +604,9 @@ func (p *AnalyticsProcessorImpl) GetProfitLossAnalytics(ctx context.Context, req
opsTotal += item.Amount opsTotal += item.Amount
} }
purchasingItems := make([]models.ProfitLossPurchasingItem, len(result.PurchasingItems))
for i, item := range result.PurchasingItems {
purchasingItems[i] = models.ProfitLossPurchasingItem{
Date: item.Date,
Item: item.Item,
Quantity: item.Quantity,
Nominal: item.Amount,
}
}
return &models.ProfitLossAnalyticsResponse{ return &models.ProfitLossAnalyticsResponse{
OrganizationID: req.OrganizationID, OrganizationID: req.OrganizationID,
OutletID: req.OutletID, OutletID: req.OutletID,
OutletName: p.resolveOutletName(ctx, req.OrganizationID, req.OutletID),
DateFrom: req.DateFrom, DateFrom: req.DateFrom,
DateTo: req.DateTo, DateTo: req.DateTo,
GroupBy: req.GroupBy, GroupBy: req.GroupBy,
@ -659,15 +626,6 @@ func (p *AnalyticsProcessorImpl) GetProfitLossAnalytics(ctx context.Context, req
Data: data, Data: data,
ProductData: productData, ProductData: productData,
MainSummary: mainSummary, MainSummary: mainSummary,
Purchasing: models.ProfitLossPurchasing{
TodayTotal: result.TodayPurchasing,
MtdTotal: result.MtdPurchasing,
TodayRawMaterial: result.TodayPurchasingRawMaterial,
MtdRawMaterial: result.MtdPurchasingRawMaterial,
TodayExpense: result.TodayPurchasingExpense,
MtdExpense: result.MtdPurchasingExpense,
Items: purchasingItems,
},
OperationalExpenses: opsItems, OperationalExpenses: opsItems,
OperationalExpensesTotal: opsTotal, OperationalExpensesTotal: opsTotal,
}, nil }, nil
@ -763,7 +721,6 @@ func (p *AnalyticsProcessorImpl) GetExclusiveSummaryMonthly(ctx context.Context,
return &models.ExclusiveSummaryMonthlyResponse{ return &models.ExclusiveSummaryMonthlyResponse{
OrganizationID: req.OrganizationID, OrganizationID: req.OrganizationID,
OutletID: req.OutletID, OutletID: req.OutletID,
OutletName: p.resolveOutletName(ctx, req.OrganizationID, req.OutletID),
Month: monthStart.Format("2006-01"), Month: monthStart.Format("2006-01"),
Summary: models.ExclusiveSummaryMonthlySummary{ Summary: models.ExclusiveSummaryMonthlySummary{
TotalSales: fullPeriod.Summary.Sales, TotalSales: fullPeriod.Summary.Sales,
@ -838,7 +795,6 @@ func (p *AnalyticsProcessorImpl) buildExclusiveSummaryPeriod(ctx context.Context
return &models.ExclusiveSummaryPeriodResponse{ return &models.ExclusiveSummaryPeriodResponse{
OrganizationID: req.OrganizationID, OrganizationID: req.OrganizationID,
OutletID: req.OutletID, OutletID: req.OutletID,
OutletName: p.resolveOutletName(ctx, req.OrganizationID, req.OutletID),
Period: models.ExclusiveSummaryPeriodRange{ Period: models.ExclusiveSummaryPeriodRange{
DateFrom: req.DateFrom, DateFrom: req.DateFrom,
DateTo: req.DateTo, DateTo: req.DateTo,

View File

@ -68,10 +68,6 @@ func (s *analyticsRepositoryStub) GetExclusiveSummaryBankBalances(context.Contex
return s.bankBalances, nil return s.bankBalances, nil
} }
func (analyticsRepositoryStub) GetOutletName(context.Context, uuid.UUID, uuid.UUID) (string, error) {
return "", nil
}
type expenseRepositoryStub struct{} type expenseRepositoryStub struct{}
func (expenseRepositoryStub) Create(context.Context, *entities.Expense) error { return nil } func (expenseRepositoryStub) Create(context.Context, *entities.Expense) error { return nil }

View File

@ -21,7 +21,6 @@ type AnalyticsRepository interface {
GetProfitLossAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, groupBy string) (*entities.ProfitLossAnalytics, error) GetProfitLossAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, groupBy string) (*entities.ProfitLossAnalytics, error)
GetExclusiveSummaryAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) (*entities.ExclusiveSummaryAnalytics, error) GetExclusiveSummaryAnalytics(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time) (*entities.ExclusiveSummaryAnalytics, error)
GetExclusiveSummaryBankBalances(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID) ([]entities.ExclusiveSummaryBankBalance, error) GetExclusiveSummaryBankBalances(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID) ([]entities.ExclusiveSummaryBankBalance, error)
GetOutletName(ctx context.Context, organizationID uuid.UUID, outletID uuid.UUID) (string, error)
} }
type AnalyticsRepositoryImpl struct { type AnalyticsRepositoryImpl struct {
@ -41,22 +40,6 @@ func (r *AnalyticsRepositoryImpl) resolveOutletID(query *gorm.DB, outletID *uuid
return query return query
} }
func (r *AnalyticsRepositoryImpl) GetOutletName(ctx context.Context, organizationID uuid.UUID, outletID uuid.UUID) (string, error) {
var outlet struct {
Name string
}
result := r.db.WithContext(ctx).
Table("outlets").
Select("name").
Where("id = ? AND organization_id = ?", outletID, organizationID).
Limit(1).
Scan(&outlet)
if result.Error != nil {
return "", result.Error
}
return outlet.Name, nil
}
func purchaseOrderItemTotalAmountSQL() string { func purchaseOrderItemTotalAmountSQL() string {
return "CASE WHEN pc.type = '" + string(entities.PurchaseCategoryTypeRawMaterial) + "' THEN COALESCE(poi.quantity, 0) * poi.amount ELSE poi.amount END" return "CASE WHEN pc.type = '" + string(entities.PurchaseCategoryTypeRawMaterial) + "' THEN COALESCE(poi.quantity, 0) * poi.amount ELSE poi.amount END"
} }
@ -488,41 +471,6 @@ func (r *AnalyticsRepositoryImpl) GetDashboardOverview(ctx context.Context, orga
return nil, err return nil, err
} }
// Total item sold (sum of order_items quantity for completed orders in date range)
var totalItemSold int64
itemQuery := r.db.WithContext(ctx).
Table("order_items oi").
Select("COALESCE(SUM(oi.quantity), 0)").
Joins("JOIN orders o ON o.id = oi.order_id").
Where("o.organization_id = ?", organizationID).
Where("o.is_void = false AND o.is_refund = false AND o.payment_status = 'completed'").
Where("o.created_at >= ? AND o.created_at <= ?", dateFrom, dateTo)
itemQuery = r.resolveOutletID(itemQuery, outletID, "o.outlet_id")
itemQuery.Scan(&totalItemSold)
result.TotalItemSold = totalItemSold
// Total low stock (inventory where quantity <= reorder_level)
var totalLowStock int64
lowStockQuery := r.db.WithContext(ctx).
Table("inventory i").
Select("COUNT(i.id)").
Joins("JOIN products p ON p.id = i.product_id").
Where("p.organization_id = ?", organizationID).
Where("i.quantity <= i.reorder_level")
lowStockQuery = r.resolveOutletID(lowStockQuery, outletID, "i.outlet_id")
lowStockQuery.Scan(&totalLowStock)
result.TotalLowStock = totalLowStock
// Total active products
var totalProductActive int64
productQuery := r.db.WithContext(ctx).
Table("products p").
Select("COUNT(p.id)").
Where("p.organization_id = ?", organizationID).
Where("p.is_active = true")
productQuery.Scan(&totalProductActive)
result.TotalProductActive = totalProductActive
return &result, nil return &result, nil
} }
@ -747,20 +695,6 @@ func (r *AnalyticsRepositoryImpl) GetProfitLossAnalytics(ctx context.Context, or
} }
opsItems = mergeOperationalExpenseItems(opsItems, poOpsItems) opsItems = mergeOperationalExpenseItems(opsItems, poOpsItems)
todayPurchasing, err := r.getPurchaseOrderTotals(ctx, organizationID, todayStart, todayEnd)
if err != nil {
return nil, err
}
mtdPurchasing, err := r.getPurchaseOrderTotals(ctx, organizationID, mtdStart, todayEnd)
if err != nil {
return nil, err
}
purchasingItems, err := r.getPurchasingItemDetails(ctx, organizationID, dateFrom, dateTo)
if err != nil {
return nil, err
}
return &entities.ProfitLossAnalytics{ return &entities.ProfitLossAnalytics{
Summary: summary, Summary: summary,
Data: data, Data: data,
@ -769,13 +703,6 @@ func (r *AnalyticsRepositoryImpl) GetProfitLossAnalytics(ctx context.Context, or
TodayCost: todayRC.Cost, TodayCost: todayRC.Cost,
MtdRevenue: mtdRC.Revenue, MtdRevenue: mtdRC.Revenue,
MtdCost: mtdRC.Cost, MtdCost: mtdRC.Cost,
TodayPurchasing: todayPurchasing.Total,
MtdPurchasing: mtdPurchasing.Total,
TodayPurchasingRawMaterial: todayPurchasing.RawMaterial,
MtdPurchasingRawMaterial: mtdPurchasing.RawMaterial,
TodayPurchasingExpense: todayPurchasing.Expense,
MtdPurchasingExpense: mtdPurchasing.Expense,
PurchasingItems: purchasingItems,
TodayExpenseByCategory: todayExpenseByCategory, TodayExpenseByCategory: todayExpenseByCategory,
MtdExpenseByCategory: mtdExpenseByCategory, MtdExpenseByCategory: mtdExpenseByCategory,
OperationalExpenseItems: opsItems, OperationalExpenseItems: opsItems,
@ -805,68 +732,6 @@ func (r *AnalyticsRepositoryImpl) getPurchaseOrderRawMaterialTotal(ctx context.C
return result.Total, nil return result.Total, nil
} }
type purchasingTotals struct {
Total float64
RawMaterial float64
Expense float64
}
func (r *AnalyticsRepositoryImpl) getPurchaseOrderTotals(ctx context.Context, organizationID uuid.UUID, dateFrom, dateTo time.Time) (purchasingTotals, error) {
type result struct {
Total float64
RawMaterial float64
Expense float64
}
var res result
query := r.db.WithContext(ctx).
Table("purchase_order_items poi").
Select(`
COALESCE(SUM(`+purchaseOrderItemTotalAmountSQL()+`), 0) as total,
COALESCE(SUM(`+purchaseOrderRawMaterialAmountSQL()+`), 0) as raw_material,
COALESCE(SUM(`+purchaseOrderExpenseAmountSQL()+`), 0) as expense
`).
Joins("JOIN purchase_orders po ON poi.purchase_order_id = po.id").
Joins("JOIN purchase_categories pc ON poi.purchase_category_id = pc.id").
Where("po.organization_id = ?", organizationID).
Where("po.status = ?", "received").
Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo)
if err := query.Scan(&res).Error; err != nil {
return purchasingTotals{}, err
}
return purchasingTotals{
Total: res.Total,
RawMaterial: res.RawMaterial,
Expense: res.Expense,
}, nil
}
func (r *AnalyticsRepositoryImpl) getPurchasingItemDetails(ctx context.Context, organizationID uuid.UUID, dateFrom, dateTo time.Time) ([]entities.PurchasingItemDetail, error) {
var results []entities.PurchasingItemDetail
query := r.db.WithContext(ctx).
Table("purchase_order_items poi").
Select(`
po.transaction_date as date,
COALESCE(NULLIF(poi.description, ''), i.name, pc.name) as item,
COALESCE(poi.quantity, 0) as quantity,
CASE WHEN pc.type = '`+string(entities.PurchaseCategoryTypeRawMaterial)+`' THEN COALESCE(poi.quantity, 0) * poi.amount ELSE poi.amount END as amount
`).
Joins("JOIN purchase_orders po ON poi.purchase_order_id = po.id").
Joins("LEFT JOIN purchase_categories pc ON poi.purchase_category_id = pc.id").
Joins("LEFT JOIN ingredients i ON poi.ingredient_id = i.id").
Where("po.organization_id = ?", organizationID).
Where("po.status = ?", "received").
Where("po.transaction_date >= ? AND po.transaction_date <= ?", dateFrom, dateTo).
Order("po.transaction_date DESC, poi.created_at DESC")
if err := query.Scan(&results).Error; err != nil {
return nil, err
}
return results, nil
}
func (r *AnalyticsRepositoryImpl) getPurchaseOrderRawMaterialCostByPeriod(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, groupBy string) ([]entities.ProfitLossData, error) { func (r *AnalyticsRepositoryImpl) getPurchaseOrderRawMaterialCostByPeriod(ctx context.Context, organizationID uuid.UUID, outletID *uuid.UUID, dateFrom, dateTo time.Time, groupBy string) ([]entities.ProfitLossData, error) {
var dateFormat string var dateFormat string
switch groupBy { switch groupBy {

View File

@ -356,7 +356,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
} }
ingredients := protected.Group("/ingredients") ingredients := protected.Group("/ingredients")
ingredients.Use(r.authMiddleware.RequireAdminOrManagerOrPurchasing()) ingredients.Use(r.authMiddleware.RequireAdminOrManager())
{ {
ingredients.POST("", r.ingredientHandler.Create) ingredients.POST("", r.ingredientHandler.Create)
ingredients.GET("", r.ingredientHandler.GetAll) ingredients.GET("", r.ingredientHandler.GetAll)
@ -369,7 +369,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
} }
vendors := protected.Group("/vendors") vendors := protected.Group("/vendors")
vendors.Use(r.authMiddleware.RequireAdminOrManagerOrPurchasing()) vendors.Use(r.authMiddleware.RequireAdminOrManager())
{ {
vendors.POST("", r.vendorHandler.CreateVendor) vendors.POST("", r.vendorHandler.CreateVendor)
vendors.GET("", r.vendorHandler.ListVendors) vendors.GET("", r.vendorHandler.ListVendors)
@ -380,7 +380,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
} }
purchaseOrders := protected.Group("/purchase-orders") purchaseOrders := protected.Group("/purchase-orders")
purchaseOrders.Use(r.authMiddleware.RequireAdminOrManagerOrPurchasing()) purchaseOrders.Use(r.authMiddleware.RequireAdminOrManager())
{ {
purchaseOrders.POST("", r.purchaseOrderHandler.CreatePurchaseOrder) purchaseOrders.POST("", r.purchaseOrderHandler.CreatePurchaseOrder)
purchaseOrders.GET("", r.purchaseOrderHandler.ListPurchaseOrders) purchaseOrders.GET("", r.purchaseOrderHandler.ListPurchaseOrders)
@ -393,7 +393,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
} }
purchaseCategories := protected.Group("/purchase-categories") purchaseCategories := protected.Group("/purchase-categories")
purchaseCategories.Use(r.authMiddleware.RequireAdminOrManagerOrPurchasing()) purchaseCategories.Use(r.authMiddleware.RequireAdminOrManager())
{ {
purchaseCategories.POST("", r.purchaseCategoryHandler.CreatePurchaseCategory) purchaseCategories.POST("", r.purchaseCategoryHandler.CreatePurchaseCategory)
purchaseCategories.GET("", r.purchaseCategoryHandler.ListPurchaseCategories) purchaseCategories.GET("", r.purchaseCategoryHandler.ListPurchaseCategories)
@ -403,7 +403,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
} }
unitConverters := protected.Group("/unit-converters") unitConverters := protected.Group("/unit-converters")
unitConverters.Use(r.authMiddleware.RequireAdminOrManagerOrPurchasing()) unitConverters.Use(r.authMiddleware.RequireAdminOrManager())
{ {
unitConverters.POST("", r.unitConverterHandler.CreateIngredientUnitConverter) unitConverters.POST("", r.unitConverterHandler.CreateIngredientUnitConverter)
unitConverters.GET("", r.unitConverterHandler.ListIngredientUnitConverters) unitConverters.GET("", r.unitConverterHandler.ListIngredientUnitConverters)
@ -465,7 +465,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
} }
expenses := protected.Group("/expenses") expenses := protected.Group("/expenses")
expenses.Use(r.authMiddleware.RequireAdminOrManagerOrPurchasing()) expenses.Use(r.authMiddleware.RequireAdminOrManager())
{ {
expenses.POST("", r.expenseHandler.CreateExpense) expenses.POST("", r.expenseHandler.CreateExpense)
expenses.GET("", r.expenseHandler.ListExpenses) expenses.GET("", r.expenseHandler.ListExpenses)
@ -620,7 +620,6 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
outlets.GET("/:outlet_id/tables/occupied", r.tableHandler.GetOccupiedTables) outlets.GET("/:outlet_id/tables/occupied", r.tableHandler.GetOccupiedTables)
// Reports // Reports
outlets.GET("/:outlet_id/reports/daily-transaction.pdf", r.reportHandler.GetDailyTransactionReportPDF) outlets.GET("/:outlet_id/reports/daily-transaction.pdf", r.reportHandler.GetDailyTransactionReportPDF)
outlets.GET("/:outlet_id/reports/profit-loss.pdf", r.reportHandler.GetProfitLossReportPDF)
} }
// User device routes - accessible by authenticated users for their own devices // User device routes - accessible by authenticated users for their own devices

View File

@ -17,7 +17,6 @@ import (
type ReportService interface { type ReportService interface {
// Returns (publicURL, fileName, error) // Returns (publicURL, fileName, error)
GenerateDailyTransactionPDF(ctx context.Context, organizationID string, outletID string, reportDate *time.Time, generatedBy string) (string, string, error) GenerateDailyTransactionPDF(ctx context.Context, organizationID string, outletID string, reportDate *time.Time, generatedBy string) (string, string, error)
GenerateProfitLossPDF(ctx context.Context, organizationID string, outletID string, reportDate *time.Time, generatedBy string) (string, string, error)
} }
type ReportServiceImpl struct { type ReportServiceImpl struct {
@ -219,296 +218,3 @@ func getPLPctByID(rows []models.ProfitLossSummaryRow, id string) float64 {
} }
return 0 return 0
} }
// profitLossReportData holds data for the profit/loss PDF template
type profitLossReportData struct {
OrganizationName string
MonthName string
ReportDate string
ReportDateUpper string
TotalPenjualan string
TotalBiaya string
LabaRugi string
LabaRugiClass string
LabaRugiValueClass string
LabaRugiMtd string
LabaRugiMtdClass string
LabaRugiMtdValueClass string
MainSummary []profitLossSummaryRowView
PurchasingItems []profitLossPurchasingItem
PurchasingTotal string
GeneratedBy string
PrintTime string
}
type profitLossSummaryRowView struct {
Number string
Label string
TodayNominal string
TodayPct string
MtdNominal string
MtdPct string
RowClass string
SubItems []profitLossSummaryRowView
}
type profitLossPurchasingItem struct {
Name string
Amount string
}
func (s *ReportServiceImpl) GenerateProfitLossPDF(ctx context.Context, organizationID string, outletID string, reportDate *time.Time, generatedBy string) (string, string, error) {
orgID, err := uuid.Parse(organizationID)
if err != nil {
return "", "", fmt.Errorf("invalid organization id: %w", err)
}
var outID *uuid.UUID
if outletID != "" {
parsed, err := uuid.Parse(outletID)
if err != nil {
return "", "", fmt.Errorf("invalid outlet id: %w", err)
}
outID = &parsed
}
org, err := s.organizationRepo.GetByID(ctx, orgID)
if err != nil {
return "", "", fmt.Errorf("organization not found: %w", err)
}
var tzName string
if outID != nil {
outlet, err := s.outletRepo.GetByID(ctx, *outID)
if err == nil && outlet.Timezone != nil && *outlet.Timezone != "" {
tzName = *outlet.Timezone
}
}
if tzName == "" {
tzName = "Asia/Jakarta"
}
loc, locErr := time.LoadLocation(tzName)
if locErr != nil || loc == nil {
loc = time.Local
}
var day time.Time
if reportDate != nil {
t := reportDate.UTC()
day = time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, loc)
} else {
now := time.Now().In(loc)
day = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
}
dayStart := day
dayEnd := day.Add(24*time.Hour - time.Nanosecond)
// MTD: from 1st of month to end of the report day
mtdStart := time.Date(day.Year(), day.Month(), 1, 0, 0, 0, 0, loc)
mtdEnd := dayEnd
// Get profit/loss analytics for the day
plReq := &models.ProfitLossAnalyticsRequest{
OrganizationID: orgID,
OutletID: outID,
DateFrom: dayStart,
DateTo: mtdEnd,
GroupBy: "day",
}
pl, err := s.analyticsService.GetProfitLossAnalytics(ctx, plReq)
if err != nil {
return "", "", fmt.Errorf("get profit/loss analytics: %w", err)
}
// Get purchasing analytics for the day (Rincian Biaya / Catatan)
purchReq := &models.PurchasingAnalyticsRequest{
OrganizationID: orgID,
OutletID: outID,
DateFrom: dayStart,
DateTo: dayEnd,
GroupBy: "day",
}
purch, err := s.analyticsService.GetPurchasingAnalytics(ctx, purchReq)
if err != nil {
return "", "", fmt.Errorf("get purchasing analytics: %w", err)
}
// Build summary values
totalOmset := getPLNominalByID(pl.MainSummary, "total_omset")
hpp := getPLNominalByID(pl.MainSummary, "hpp")
_ = mtdStart // used above
// Total biaya = HPP + operational expenses for the day
totalBiayaToday := hpp + pl.OperationalExpensesTotal
// Laba/Rugi today
labaRugiToday := totalOmset - totalBiayaToday
// MTD values
mtdOmset := getMtdNominalByID(pl.MainSummary, "total_omset")
mtdCost := getMtdNominalByID(pl.MainSummary, "hpp")
mtdOps := getMtdNominalByID(pl.MainSummary, "biaya_ops")
mtdGaji := getMtdNominalByID(pl.MainSummary, "biaya_gaji")
labaRugiMtd := mtdOmset - mtdCost - mtdOps - mtdGaji
// Month name in Indonesian
monthNames := []string{"", "Januari", "Februari", "Maret", "April", "Mei", "Juni", "Juli", "Agustus", "September", "Oktober", "November", "Desember"}
monthName := fmt.Sprintf("%s %d", monthNames[day.Month()], day.Year())
reportDateStr := fmt.Sprintf("%d %s %d", day.Day(), monthNames[day.Month()], day.Year())
reportDateUpper := fmt.Sprintf("%d %s %d", day.Day(), strings.ToUpper(monthNames[day.Month()]), day.Year())
// Build main summary rows
mainSummaryRows := buildProfitLossSummaryRows(pl.MainSummary)
// Build purchasing items from ingredient data
purchItems := make([]profitLossPurchasingItem, 0)
var purchTotal float64
for _, item := range purch.IngredientData {
purchItems = append(purchItems, profitLossPurchasingItem{
Name: item.IngredientName,
Amount: formatCurrency(item.TotalCost),
})
purchTotal += item.TotalCost
}
// Determine highlight classes
labaRugiClass := ""
labaRugiValueClass := ""
if labaRugiToday < 0 {
labaRugiClass = "highlight-red"
labaRugiValueClass = "negative"
} else {
labaRugiClass = "highlight-green"
labaRugiValueClass = "positive"
}
labaRugiMtdClass := ""
labaRugiMtdValueClass := ""
if labaRugiMtd < 0 {
labaRugiMtdClass = "highlight-red"
labaRugiMtdValueClass = "negative"
} else {
labaRugiMtdClass = "highlight-green"
labaRugiMtdValueClass = "positive"
}
data := profitLossReportData{
OrganizationName: org.Name,
MonthName: monthName,
ReportDate: reportDateStr,
ReportDateUpper: reportDateUpper,
TotalPenjualan: formatCurrency(totalOmset),
TotalBiaya: formatCurrency(totalBiayaToday),
LabaRugi: formatCurrencySigned(labaRugiToday),
LabaRugiClass: labaRugiClass,
LabaRugiValueClass: labaRugiValueClass,
LabaRugiMtd: formatCurrencySigned(labaRugiMtd),
LabaRugiMtdClass: labaRugiMtdClass,
LabaRugiMtdValueClass: labaRugiMtdValueClass,
MainSummary: mainSummaryRows,
PurchasingItems: purchItems,
PurchasingTotal: formatCurrency(purchTotal),
GeneratedBy: generatedBy,
PrintTime: time.Now().In(loc).Format("02/01/2006 15:04:05"),
}
templatePath := filepath.Join("templates", "profit_loss_report.html")
pdfBytes, err := renderTemplateToPDF(templatePath, data)
if err != nil {
return "", "", fmt.Errorf("render pdf: %w", err)
}
safeOrg := orgID.String()
safeOutlet := "all"
if outID != nil {
safeOutlet = outID.String()
}
fileName := fmt.Sprintf("laporan-laba-rugi-%s-%s.pdf", day.Format("2006-01-02"), time.Now().Format("20060102-150405"))
objectKey := fmt.Sprintf("/reports/%s/%s/%s", safeOrg, safeOutlet, fileName)
publicURL, err := s.fileClient.UploadFile(ctx, objectKey, pdfBytes)
if err != nil {
return "", "", fmt.Errorf("upload pdf: %w", err)
}
return publicURL, fileName, nil
}
func getMtdNominalByID(rows []models.ProfitLossSummaryRow, id string) float64 {
for _, row := range rows {
if row.ID == id {
return row.MtdNominal
}
}
return 0
}
func buildProfitLossSummaryRows(rows []models.ProfitLossSummaryRow) []profitLossSummaryRowView {
result := make([]profitLossSummaryRowView, 0, len(rows))
for i, row := range rows {
rowClass := ""
if row.IsBold {
rowClass = "highlight-green-row"
}
// Highlight laba kotor row
if row.ID == "laba_kotor" {
rowClass = "highlight-row"
}
number := ""
if row.ID != "" {
number = fmt.Sprintf("%d", i+1)
}
subItems := make([]profitLossSummaryRowView, 0)
for _, sub := range row.SubItems {
subItems = append(subItems, profitLossSummaryRowView{
Label: sub.Label,
TodayNominal: formatCurrencyOrDash(sub.TodayNominal),
TodayPct: formatPct(sub.TodayPct),
MtdNominal: formatCurrencyOrDash(sub.MtdNominal),
MtdPct: formatPct(sub.MtdPct),
RowClass: "",
})
}
result = append(result, profitLossSummaryRowView{
Number: number,
Label: row.Label,
TodayNominal: formatCurrencyOrDash(row.TodayNominal),
TodayPct: formatPct(row.TodayPct),
MtdNominal: formatCurrencyOrDash(row.MtdNominal),
MtdPct: formatPct(row.MtdPct),
RowClass: rowClass,
SubItems: subItems,
})
}
return result
}
func formatCurrencyOrDash(amount float64) string {
if amount == 0 {
return "-"
}
if amount < 0 {
return formatCurrencySigned(amount)
}
return formatCurrency(amount)
}
func formatCurrencySigned(amount float64) string {
if amount < 0 {
return "(Rp " + addThousandsSep(fmt.Sprintf("%.0f", -amount)) + ")"
}
return "Rp " + addThousandsSep(fmt.Sprintf("%.0f", amount))
}
func formatPct(pct float64) string {
if pct == 0 {
return "0%"
}
return fmt.Sprintf("%.0f%%", pct)
}

View File

@ -66,7 +66,6 @@ func PaymentMethodAnalyticsModelToContract(resp *models.PaymentMethodAnalyticsRe
return &contract.PaymentMethodAnalyticsResponse{ return &contract.PaymentMethodAnalyticsResponse{
OrganizationID: resp.OrganizationID, OrganizationID: resp.OrganizationID,
OutletID: resp.OutletID, OutletID: resp.OutletID,
OutletName: resp.OutletName,
DateFrom: resp.DateFrom, DateFrom: resp.DateFrom,
DateTo: resp.DateTo, DateTo: resp.DateTo,
GroupBy: resp.GroupBy, GroupBy: resp.GroupBy,
@ -123,7 +122,6 @@ func SalesAnalyticsModelToContract(resp *models.SalesAnalyticsResponse) *contrac
return &contract.SalesAnalyticsResponse{ return &contract.SalesAnalyticsResponse{
OrganizationID: resp.OrganizationID, OrganizationID: resp.OrganizationID,
OutletID: resp.OutletID, OutletID: resp.OutletID,
OutletName: resp.OutletName,
DateFrom: resp.DateFrom, DateFrom: resp.DateFrom,
DateTo: resp.DateTo, DateTo: resp.DateTo,
GroupBy: resp.GroupBy, GroupBy: resp.GroupBy,
@ -287,7 +285,6 @@ func ProductAnalyticsModelToContract(resp *models.ProductAnalyticsResponse) *con
return &contract.ProductAnalyticsResponse{ return &contract.ProductAnalyticsResponse{
OrganizationID: resp.OrganizationID, OrganizationID: resp.OrganizationID,
OutletID: resp.OutletID, OutletID: resp.OutletID,
OutletName: resp.OutletName,
DateFrom: resp.DateFrom, DateFrom: resp.DateFrom,
DateTo: resp.DateTo, DateTo: resp.DateTo,
Data: data, Data: data,
@ -340,7 +337,6 @@ func ProductAnalyticsPerCategoryModelToContract(resp *models.ProductAnalyticsPer
return &contract.ProductAnalyticsPerCategoryResponse{ return &contract.ProductAnalyticsPerCategoryResponse{
OrganizationID: resp.OrganizationID, OrganizationID: resp.OrganizationID,
OutletID: resp.OutletID, OutletID: resp.OutletID,
OutletName: resp.OutletName,
DateFrom: resp.DateFrom, DateFrom: resp.DateFrom,
DateTo: resp.DateTo, DateTo: resp.DateTo,
Data: data, Data: data,
@ -425,7 +421,6 @@ func DashboardAnalyticsModelToContract(resp *models.DashboardAnalyticsResponse)
return &contract.DashboardAnalyticsResponse{ return &contract.DashboardAnalyticsResponse{
OrganizationID: resp.OrganizationID, OrganizationID: resp.OrganizationID,
OutletID: resp.OutletID, OutletID: resp.OutletID,
OutletName: resp.OutletName,
DateFrom: resp.DateFrom, DateFrom: resp.DateFrom,
DateTo: resp.DateTo, DateTo: resp.DateTo,
Overview: contract.DashboardOverview{ Overview: contract.DashboardOverview{
@ -435,9 +430,6 @@ func DashboardAnalyticsModelToContract(resp *models.DashboardAnalyticsResponse)
TotalCustomers: resp.Overview.TotalCustomers, TotalCustomers: resp.Overview.TotalCustomers,
VoidedOrders: resp.Overview.VoidedOrders, VoidedOrders: resp.Overview.VoidedOrders,
RefundedOrders: resp.Overview.RefundedOrders, RefundedOrders: resp.Overview.RefundedOrders,
TotalItemSold: resp.Overview.TotalItemSold,
TotalLowStock: resp.Overview.TotalLowStock,
TotalProductActive: resp.Overview.TotalProductActive,
}, },
TopProducts: topProducts, TopProducts: topProducts,
PaymentMethods: paymentMethods, PaymentMethods: paymentMethods,
@ -524,20 +516,9 @@ func ProfitLossAnalyticsModelToContract(resp *models.ProfitLossAnalyticsResponse
} }
} }
purchasingItems := make([]contract.ProfitLossPurchasingItem, len(resp.Purchasing.Items))
for i, item := range resp.Purchasing.Items {
purchasingItems[i] = contract.ProfitLossPurchasingItem{
Date: item.Date,
Item: item.Item,
Quantity: item.Quantity,
Nominal: item.Nominal,
}
}
return &contract.ProfitLossAnalyticsResponse{ return &contract.ProfitLossAnalyticsResponse{
OrganizationID: resp.OrganizationID, OrganizationID: resp.OrganizationID,
OutletID: resp.OutletID, OutletID: resp.OutletID,
OutletName: resp.OutletName,
DateFrom: resp.DateFrom, DateFrom: resp.DateFrom,
DateTo: resp.DateTo, DateTo: resp.DateTo,
GroupBy: resp.GroupBy, GroupBy: resp.GroupBy,
@ -557,15 +538,6 @@ func ProfitLossAnalyticsModelToContract(resp *models.ProfitLossAnalyticsResponse
Data: data, Data: data,
ProductData: productData, ProductData: productData,
MainSummary: mainSummary, MainSummary: mainSummary,
Purchasing: contract.ProfitLossPurchasing{
TodayTotal: resp.Purchasing.TodayTotal,
MtdTotal: resp.Purchasing.MtdTotal,
TodayRawMaterial: resp.Purchasing.TodayRawMaterial,
MtdRawMaterial: resp.Purchasing.MtdRawMaterial,
TodayExpense: resp.Purchasing.TodayExpense,
MtdExpense: resp.Purchasing.MtdExpense,
Items: purchasingItems,
},
OperationalExpenses: opsItems, OperationalExpenses: opsItems,
OperationalExpensesTotal: resp.OperationalExpensesTotal, OperationalExpensesTotal: resp.OperationalExpensesTotal,
} }
@ -692,7 +664,6 @@ func ExclusiveSummaryPeriodModelToContract(resp *models.ExclusiveSummaryPeriodRe
return &contract.ExclusiveSummaryPeriodResponse{ return &contract.ExclusiveSummaryPeriodResponse{
OrganizationID: resp.OrganizationID, OrganizationID: resp.OrganizationID,
OutletID: resp.OutletID, OutletID: resp.OutletID,
OutletName: resp.OutletName,
Period: contract.ExclusiveSummaryPeriodRange{ Period: contract.ExclusiveSummaryPeriodRange{
DateFrom: resp.Period.DateFrom, DateFrom: resp.Period.DateFrom,
DateTo: resp.Period.DateTo, DateTo: resp.Period.DateTo,
@ -755,7 +726,6 @@ func ExclusiveSummaryMonthlyModelToContract(resp *models.ExclusiveSummaryMonthly
return &contract.ExclusiveSummaryMonthlyResponse{ return &contract.ExclusiveSummaryMonthlyResponse{
OrganizationID: resp.OrganizationID, OrganizationID: resp.OrganizationID,
OutletID: resp.OutletID, OutletID: resp.OutletID,
OutletName: resp.OutletName,
Month: resp.Month, Month: resp.Month,
Summary: contract.ExclusiveSummaryMonthlySummary{ Summary: contract.ExclusiveSummaryMonthlySummary{
TotalSales: resp.Summary.TotalSales, TotalSales: resp.Summary.TotalSales,

View File

@ -144,8 +144,6 @@ func isValidUserRole(role string) bool {
string(constants.RoleManager): true, string(constants.RoleManager): true,
string(constants.RoleCashier): true, string(constants.RoleCashier): true,
string(constants.RoleWaiter): true, string(constants.RoleWaiter): true,
string(constants.RoleOwner): true,
string(constants.RolePurchasing): true,
} }
return validRoles[role] return validRoles[role]
} }

View File

@ -7,7 +7,7 @@ ON purchase_orders(outlet_id);
WITH movement_outlets AS ( WITH movement_outlets AS (
SELECT SELECT
poi.purchase_order_id, poi.purchase_order_id,
MIN(im.outlet_id::text)::uuid AS outlet_id MIN(im.outlet_id) AS outlet_id
FROM inventory_movements im FROM inventory_movements im
JOIN purchase_order_items poi ON im.purchase_order_item_id = poi.id JOIN purchase_order_items poi ON im.purchase_order_item_id = poi.id
WHERE im.outlet_id IS NOT NULL WHERE im.outlet_id IS NOT NULL
@ -40,7 +40,7 @@ WITH candidate_item_outlets AS (
), item_outlets AS ( ), item_outlets AS (
SELECT SELECT
purchase_order_id, purchase_order_id,
MIN(outlet_id::text)::uuid AS outlet_id MIN(outlet_id) AS outlet_id
FROM candidate_item_outlets FROM candidate_item_outlets
GROUP BY purchase_order_id GROUP BY purchase_order_id
HAVING COUNT(DISTINCT outlet_id) = 1 HAVING COUNT(DISTINCT outlet_id) = 1
@ -54,7 +54,7 @@ WHERE po.id = item_outlets.purchase_order_id
WITH single_outlet_organizations AS ( WITH single_outlet_organizations AS (
SELECT SELECT
organization_id, organization_id,
MIN(id::text)::uuid AS outlet_id MIN(id) AS outlet_id
FROM outlets FROM outlets
GROUP BY organization_id GROUP BY organization_id
HAVING COUNT(*) = 1 HAVING COUNT(*) = 1

View File

@ -1,4 +0,0 @@
-- Revert to original roles
ALTER TABLE users DROP CONSTRAINT IF EXISTS users_role_check;
UPDATE users SET role = 'admin' WHERE role NOT IN ('admin', 'manager', 'cashier', 'waiter');
ALTER TABLE users ADD CONSTRAINT users_role_check CHECK (role IN ('admin', 'manager', 'cashier', 'waiter'));

View File

@ -1,4 +0,0 @@
-- Add 'owner' and 'purchasing' roles to users table
ALTER TABLE users DROP CONSTRAINT IF EXISTS users_role_check;
UPDATE users SET role = 'admin' WHERE role NOT IN ('admin', 'manager', 'cashier', 'waiter', 'owner', 'purchasing');
ALTER TABLE users ADD CONSTRAINT users_role_check CHECK (role IN ('admin', 'manager', 'cashier', 'waiter', 'owner', 'purchasing'));

View File

@ -1,394 +0,0 @@
<!doctype html>
<html lang="id">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Laporan Penjualan Harian</title>
<style>
@page {
size: A4;
margin: 10mm 12mm;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
background: #ffffff;
color: #2d3748;
line-height: 1.4;
font-size: 11px;
}
.report-container {
max-width: 210mm;
margin: 0 auto;
padding: 0;
}
/* Header */
.report-header {
text-align: center;
margin-bottom: 20px;
}
.header-logo {
display: inline-block;
width: 60px;
height: 60px;
border-radius: 10px;
overflow: hidden;
margin-bottom: 8px;
background: #dc2626;
padding: 10px;
}
.header-logo svg {
width: 40px;
height: 40px;
}
.header-title {
font-size: 22px;
font-weight: 800;
color: #1a1a1a;
margin-bottom: 4px;
}
.header-org {
font-size: 14px;
font-weight: 700;
color: #dc2626;
margin-bottom: 2px;
}
.header-period {
font-size: 11px;
color: #666;
font-style: italic;
}
/* Summary Boxes */
.summary-boxes {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0;
margin-bottom: 20px;
border: 2px solid #1a1a1a;
}
.summary-box {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 14px;
border-bottom: 1px solid #1a1a1a;
}
.summary-box:nth-child(odd) {
border-right: 1px solid #1a1a1a;
}
.summary-box:nth-child(n + 3) {
border-bottom: none;
}
.summary-box.highlight-green {
background: #dcfce7;
}
.summary-box.highlight-red {
background: #fee2e2;
}
.summary-box-label {
font-size: 10px;
font-weight: 700;
color: #1a1a1a;
text-transform: uppercase;
}
.summary-box-value {
font-size: 13px;
font-weight: 800;
color: #1a1a1a;
font-family: "Courier New", monospace;
}
.summary-box-value.negative {
color: #dc2626;
}
.summary-box-value.positive {
color: #16a34a;
}
/* Section Title */
.section-title {
font-size: 14px;
font-weight: 800;
color: #dc2626;
margin-bottom: 10px;
margin-top: 20px;
}
/* Main Summary Table */
.summary-table {
width: 100%;
border-collapse: collapse;
margin-bottom: 20px;
font-size: 11px;
}
.summary-table thead th {
background: #dc2626;
color: #ffffff;
padding: 8px 10px;
text-align: center;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
border: 1px solid #dc2626;
}
.summary-table thead th:first-child {
width: 30px;
}
.summary-table thead th:nth-child(2) {
text-align: left;
width: 35%;
}
.summary-table tbody td {
padding: 7px 10px;
border: 1px solid #e5e7eb;
font-size: 11px;
}
.summary-table tbody td:first-child {
text-align: center;
font-weight: 600;
}
.summary-table tbody td.nominal {
text-align: right;
font-family: "Courier New", monospace;
font-weight: 600;
}
.summary-table tbody td.pct {
text-align: center;
font-weight: 600;
}
.summary-table tbody tr.bold-row td {
font-weight: 800;
background: #f9fafb;
}
.summary-table tbody tr.highlight-row td {
font-weight: 800;
background: #fef3c7;
}
.summary-table tbody tr.highlight-green-row td {
font-weight: 800;
background: #dcfce7;
}
.summary-table tbody tr.sub-item td {
padding-left: 30px;
font-size: 10px;
color: #4b5563;
}
/* Rincian Biaya Table */
.detail-table {
width: 100%;
border-collapse: collapse;
margin-bottom: 20px;
font-size: 11px;
}
.detail-table thead th {
background: #dc2626;
color: #ffffff;
padding: 8px 10px;
text-align: center;
font-size: 10px;
font-weight: 700;
text-transform: uppercase;
border: 1px solid #dc2626;
}
.detail-table thead th:first-child {
width: 30px;
}
.detail-table thead th:nth-child(2) {
text-align: left;
}
.detail-table thead th:last-child {
text-align: right;
width: 25%;
}
.detail-table tbody td {
padding: 7px 10px;
border: 1px solid #e5e7eb;
font-size: 11px;
}
.detail-table tbody td:first-child {
text-align: center;
font-weight: 600;
}
.detail-table tbody td:last-child {
text-align: right;
font-family: "Courier New", monospace;
font-weight: 600;
}
.detail-table tbody tr.total-row td {
font-weight: 800;
background: #fef3c7;
border-top: 2px solid #1a1a1a;
}
/* Footer */
.report-footer {
margin-top: 20px;
padding-top: 10px;
border-top: 1px solid #e5e7eb;
font-size: 9px;
color: #9ca3af;
text-align: center;
}
</style>
</head>
<body>
<div class="report-container">
<!-- HEADER -->
<div class="report-header">
<div class="header-logo">
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 500 500"
width="40"
height="40"
>
<rect fill="#dc2626" x="0" y="0" width="500" height="500" rx="60" />
<g transform="translate(250,250) scale(3.2)">
<rect
x="-40"
y="-50"
width="80"
height="10"
rx="3"
fill="white"
/>
<rect
x="-30"
y="-35"
width="60"
height="60"
rx="5"
fill="none"
stroke="white"
stroke-width="5"
/>
<circle cx="0" cy="-55" r="8" fill="white" />
<line
x1="-10"
y1="-60"
x2="10"
y2="-50"
stroke="white"
stroke-width="3"
/>
<line
x1="10"
y1="-60"
x2="-10"
y2="-50"
stroke="white"
stroke-width="3"
/>
</g>
</svg>
</div>
<div class="header-title">LAPORAN PENJUALAN HARIAN</div>
<div class="header-org">{{.OrganizationName}}</div>
<div class="header-period">
Bulan: {{.MonthName}} / Tanggal Report: {{.ReportDate}}
</div>
</div>
<!-- SUMMARY BOXES -->
<div class="summary-boxes">
<div class="summary-box">
<span class="summary-box-label"
>TOTAL PENJUALAN {{.ReportDateUpper}}</span
>
<span class="summary-box-value">{{.TotalPenjualan}}</span>
</div>
<div class="summary-box">
<span class="summary-box-label"
>TOTAL BIAYA {{.ReportDateUpper}}</span
>
<span class="summary-box-value">{{.TotalBiaya}}</span>
</div>
<div class="summary-box {{.LabaRugiClass}}">
<span class="summary-box-label">LABA/RUGI {{.ReportDateUpper}}</span>
<span class="summary-box-value {{.LabaRugiValueClass}}"
>{{.LabaRugi}}</span
>
</div>
<div class="summary-box {{.LabaRugiMtdClass}}">
<span class="summary-box-label">LABA/RUGI MTD</span>
<span class="summary-box-value {{.LabaRugiMtdValueClass}}"
>{{.LabaRugiMtd}}</span
>
</div>
</div>
<!-- 1. RINGKASAN LAPORAN -->
<div class="section-title">1. Ringkasan Laporan</div>
<table class="summary-table">
<thead>
<tr>
<th>NO</th>
<th>KETERANGAN</th>
<th>TANGGAL REPORT<br />Nominal</th>
<th>%</th>
<th>MTD<br />Nominal</th>
<th>%</th>
</tr>
</thead>
<tbody>
{{range $i, $row := .MainSummary}}
<tr class="{{$row.RowClass}}">
<td>{{if $row.Number}}{{$row.Number}}{{end}}</td>
<td>{{$row.Label}}</td>
<td class="nominal">{{$row.TodayNominal}}</td>
<td class="pct">{{$row.TodayPct}}</td>
<td class="nominal">{{$row.MtdNominal}}</td>
<td class="pct">{{$row.MtdPct}}</td>
</tr>
{{range $j, $sub := $row.SubItems}}
<tr class="sub-item">
<td></td>
<td>{{$sub.Label}}</td>
<td class="nominal">{{$sub.TodayNominal}}</td>
<td class="pct">{{$sub.TodayPct}}</td>
<td class="nominal">{{$sub.MtdNominal}}</td>
<td class="pct">{{$sub.MtdPct}}</td>
</tr>
{{end}} {{end}}
</tbody>
</table>
<!-- 2. RINCIAN BIAYA / CATATAN -->
<div class="section-title">2. Rincian Biaya / Catatan</div>
<table class="detail-table">
<thead>
<tr>
<th>NO</th>
<th>KETERANGAN</th>
<th>JUMLAH</th>
</tr>
</thead>
<tbody>
{{range $i, $item := .PurchasingItems}}
<tr>
<td>{{add $i 1}}</td>
<td>{{$item.Name}}</td>
<td>{{$item.Amount}}</td>
</tr>
{{end}}
<tr class="total-row">
<td></td>
<td>TOTAL</td>
<td>{{.PurchasingTotal}}</td>
</tr>
</tbody>
</table>
<!-- FOOTER -->
<div class="report-footer">
Dicetak oleh: {{.GeneratedBy}} | {{.PrintTime}} | Powered by APSKEL
</div>
</div>
</body>
</html>